'use client' import { useEffect, useMemo, useState } from 'react' import { useLocale, useTranslations } from 'next-intl' import { Loader2 } from 'lucide-react' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' import { DEFAULT_REVENUE_ACCOUNT_BY_RATE, ROUNDING_ACCOUNT, resolveBookingWarnings, resolvePaymentAccount, unsupportedVatRates, } from '@/lib/webshop-orders/booking-lines' import { ACCOUNT_NUMBER_RE } from '@/lib/invariants/account-number' import { roundOre } from '@/lib/money' import { formatCurrency } from '@/lib/utils' import type { WebshopOrder, WebshopStoreSettings } from '@/types' interface BulkBookOrderResult { order_id: string order_number: string | null success: boolean journal_entry_id?: string voucher_series?: string | null voucher_number?: number | null error?: { code: string; message: string; message_en?: string } } interface BulkOrderBookingDialogProps { open: boolean onOpenChange: (open: boolean) => void orders: WebshopOrder[] settingsFor: (order: WebshopOrder) => WebshopStoreSettings | null /** Called after the server processed the batch (also on partial failure). */ onBooked: () => void } /** * Book N selected orders with the standard order template in one sweep * (confirm up front, convention 10). Each order still becomes its own * verifikat server-side via the same flow as the single-order dialog; this * dialog chooses two sweep-wide policies: the payment counter-account * (per-store mapping by default, or one explicit account) and the revenue * template (revenue account per VAT rate, standard 3001-series by default). * Partial failure is surfaced per order in a result list instead of * aborting the batch. */ export default function BulkOrderBookingDialog({ open, onOpenChange, orders, settingsFor, onBooked, }: BulkOrderBookingDialogProps) { const t = useTranslations('webshop_orders') const locale = useLocale() const { toast } = useToast() const [overrideEnabled, setOverrideEnabled] = useState(false) const [overrideAccount, setOverrideAccount] = useState('') const [revenueEnabled, setRevenueEnabled] = useState(false) const [revenueAccounts, setRevenueAccounts] = useState>({}) const [submitting, setSubmitting] = useState(false) const [results, setResults] = useState(null) // Client-side mirror of the server's review guards, so the confirmation // describes exactly what will book (convention 10) instead of promising a // sweep the server then partially refuses: // - empty vat_breakdown: the prefill would be a ratio-inferred GUESS that // only the single dialog's editable form may show; // - a non-Swedish VAT rate (foreign OSS bucket): the account map would // silently fall back to the 25% accounts; // - invoice-mode mapping: the store routes this method through the invoice // flow, and booking would foreclose Skapa faktura for the order. // The server enforces the same rules for non-UI callers. const { bookableOrders, skippedMissingBreakdown, skippedUnsupportedRate, skippedInvoiceMode, } = useMemo(() => { const missing: WebshopOrder[] = [] const badRate: WebshopOrder[] = [] const invoiceMode: WebshopOrder[] = [] const bookable: WebshopOrder[] = [] for (const order of orders) { if (order.vat_breakdown.length === 0) missing.push(order) else if (unsupportedVatRates(order.vat_breakdown).length > 0) badRate.push(order) else if (resolvePaymentAccount(order, settingsFor(order)).invoiceMode) invoiceMode.push(order) else bookable.push(order) } return { bookableOrders: bookable, skippedMissingBreakdown: missing, skippedUnsupportedRate: badRate, skippedInvoiceMode: invoiceMode, } }, [orders, settingsFor]) // Signed sum per currency: refunds carry negative totals and reduce the // batch total honestly instead of inflating it. const currencyTotals = useMemo(() => { const byCurrency = new Map() for (const order of bookableOrders) { const code = order.currency.toUpperCase() byCurrency.set(code, roundOre((byCurrency.get(code) ?? 0) + order.total)) } return Array.from(byCurrency.entries()).sort(([a], [b]) => a.localeCompare(b)) }, [bookableOrders]) // Payment-method groups with their resolved counter-account, so the user // sees exactly which account each slice of the selection will book against // before confirming. const accountGroups = useMemo(() => { const groups = new Map() for (const order of bookableOrders) { const { account } = resolvePaymentAccount(order, settingsFor(order)) const label = order.payment_method_title || order.payment_method || '' const key = `${label}|${account}` const existing = groups.get(key) if (existing) existing.count += 1 else groups.set(key, { label, account, count: 1 }) } return Array.from(groups.values()).sort((a, b) => a.label.localeCompare(b.label)) }, [bookableOrders, settingsFor]) // The VAT rates actually present in the bookable selection, highest first, // with order counts: the revenue template only offers inputs for rates a // revenue line will actually book on. const ratesPresent = useMemo(() => { const counts = new Map() for (const order of bookableOrders) { const rates = new Set(order.vat_breakdown.map((b) => b.rate)) for (const rate of rates) { counts.set(rate, (counts.get(rate) ?? 0) + 1) } } return Array.from(counts.entries()) .sort(([a], [b]) => b - a) .map(([rate, count]) => ({ rate, count })) }, [bookableOrders]) // Advisory VAT warnings stay per order, not an anonymous count: the user // must be able to tell WHICH orders deserve the single-dialog review. const warningOrderNumbers = useMemo( () => bookableOrders .filter((o) => resolveBookingWarnings(o).length > 0) .map((o) => o.order_number), [bookableOrders], ) const uniformAccount = accountGroups.length > 0 && accountGroups.every((g) => g.account === accountGroups[0].account) ? accountGroups[0].account : null // Reset per open so a second sweep starts clean. The revenue inputs // prefill with the effective defaults (prefill-plus-override editor // pattern): the user edits from what would book, not from blank fields. useEffect(() => { if (open) { setOverrideEnabled(false) setOverrideAccount(uniformAccount ?? '') setRevenueEnabled(false) setRevenueAccounts({ ...DEFAULT_REVENUE_ACCOUNT_BY_RATE }) setSubmitting(false) setResults(null) } // uniformAccount is derived from the selection the dialog opened with. // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]) const overrideValid = ACCOUNT_NUMBER_RE.test(overrideAccount) // A revenue-template account must be class 3 and never 3740 (schema // mirror: 3740 is the rounding account the residual guard keys on). const revenueAccountValid = (value: string) => ACCOUNT_NUMBER_RE.test(value) && value.startsWith('3') && value !== ROUNDING_ACCOUNT const revenueAllValid = ratesPresent.every(({ rate }) => revenueAccountValid(revenueAccounts[rate] ?? ''), ) const canConfirm = !submitting && bookableOrders.length > 0 && (!overrideEnabled || overrideValid) && (!revenueEnabled || revenueAllValid) // Only DIFFS from the default map are sent (store-diffs convention). An // untouched default (e.g. 3004) must ride the default path server-side, // where our closed prefill set is auto-added to a fresh chart; sending it // explicitly would be a semantic no-op that changes nothing but intent. const revenueDiffs = useMemo(() => { if (!revenueEnabled) return null const diffs: Record = {} for (const { rate } of ratesPresent) { const chosen = revenueAccounts[rate] if (chosen && chosen !== DEFAULT_REVENUE_ACCOUNT_BY_RATE[rate]) { diffs[String(rate)] = chosen } } return Object.keys(diffs).length > 0 ? diffs : null }, [revenueEnabled, ratesPresent, revenueAccounts]) async function handleConfirm() { if (!canConfirm) return setSubmitting(true) try { const response = await fetch('/api/webshop-orders/bulk-book', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ order_ids: bookableOrders.map((o) => o.id), ...(overrideEnabled && overrideValid ? { payment_account: overrideAccount } : {}), ...(revenueDiffs ? { revenue_accounts: revenueDiffs } : {}), }), }) if (!response.ok) { const body = await response.json().catch(() => null) toast({ title: t('bulk_error_title'), description: getErrorMessage(body, { statusCode: response.status }), variant: 'destructive', }) return } const body = (await response.json()) as { data: { results: BulkBookOrderResult[] booked_count: number failed_count: number } } if (body.data.failed_count === 0) { toast({ title: t('bulk_success_title'), description: t('bulk_success_description', { count: body.data.booked_count }), variant: 'success', }) onBooked() onOpenChange(false) return } // Partial failure: keep the dialog open on the per-order report; the // list behind refreshes so the booked rows leave "att bokföra". setResults(body.data.results) onBooked() } catch (err) { toast({ title: t('bulk_error_title'), description: getErrorMessage(err), variant: 'destructive', }) } finally { setSubmitting(false) } } if (orders.length === 0) return null const failures = (results ?? []).filter((r) => !r.success) const bookedCount = (results ?? []).filter((r) => r.success).length return ( {t('bulk_title', { count: bookableOrders.length })} {t('bulk_description')} {results ? (

{t('bulk_partial_summary', { booked: bookedCount, failed: failures.length, })}

{t('bulk_failed_heading')}

    {failures.map((r) => (
  • {r.order_number ?? r.order_id} {': '} {locale === 'en' && r.error?.message_en ? r.error.message_en : (r.error?.message ?? t('bulk_unknown_error'))}
  • ))}
) : (
{bookableOrders.length > 0 && ( <>

{t('bulk_totals_label')}

    {currencyTotals.map(([code, total]) => (
  • {code} {formatCurrency(total, code)}
  • ))}

{t('bulk_account_plan_label')}

    {accountGroups.map((g) => (
  • {g.label || t('bulk_no_method')} {overrideEnabled && overrideValid ? overrideAccount : g.account} {' · '} {t('bulk_group_count', { count: g.count })}
  • ))}
{overrideEnabled && (
setOverrideAccount(e.target.value.trim())} inputMode="numeric" maxLength={4} className="w-28 tabular-nums" aria-invalid={!overrideValid} aria-describedby={ !overrideValid ? 'bulk-order-payment-account-error' : undefined } /> {!overrideValid && ( )}
)}
{/* Revenue template (bokföringsmall): revenue account per VAT rate present in the selection. Static rows show the effective accounts (convention 10); the checkbox swaps them for inputs prefilled with the same defaults. VAT accounts are derived from the rate and deliberately not editable here. */}
    {ratesPresent.map(({ rate, count }) => { const value = revenueAccounts[rate] ?? '' const valid = revenueAccountValid(value) return (
  • {t('bulk_revenue_rate', { rate })} {revenueEnabled ? ( setRevenueAccounts((prev) => ({ ...prev, [rate]: e.target.value.trim(), })) } inputMode="numeric" maxLength={4} className="w-28 tabular-nums" aria-label={t('bulk_revenue_rate_aria', { rate })} aria-invalid={!valid} /> {!valid && ( {t('invalid_revenue_account')} )} ) : ( {DEFAULT_REVENUE_ACCOUNT_BY_RATE[rate]} {' · '} {t('bulk_group_count', { count })} )}
  • ) })}
)} {/* Skip notices: these selected orders will NOT be part of the sweep (mirrored server-side); the escape hatch is the single-order dialog where the lines are reviewable. */} {skippedMissingBreakdown.length > 0 && (

{t('bulk_skipped_missing_breakdown', { numbers: skippedMissingBreakdown.map((o) => o.order_number).join(', '), })}

)} {skippedUnsupportedRate.length > 0 && (

{t('bulk_skipped_unsupported_rate', { numbers: skippedUnsupportedRate.map((o) => o.order_number).join(', '), })}

)} {skippedInvoiceMode.length > 0 && (

{t('bulk_skipped_invoice_mode', { numbers: skippedInvoiceMode.map((o) => o.order_number).join(', '), })}

)} {/* Advisory only (soft-guard rule): the sweep still books these; the named orders are better reviewed one by one. */} {warningOrderNumbers.length > 0 && (

{t('bulk_warning_orders', { numbers: warningOrderNumbers.join(', ') })}

)} {bookableOrders.length === 0 && (

{t('bulk_none_bookable')}

)}
)} {results ? ( ) : ( <> )}
) }