'use client' import { useCallback, useEffect, useRef, useState } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useLocale, useTranslations } from 'next-intl' import { MoreHorizontal, ShoppingCart } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' import { useToast } from '@/components/ui/use-toast' import { ContextPicker } from '@/components/common/ContextPicker' import { TH_CLASS, TD_CLASS, QUIET_LINK_CLASS, CHECKBOX_REVEAL_CLASS } from '@/components/ui/dry-table' import { useRangeSelect } from '@/lib/hooks/use-range-select' import { cn, formatCurrency, formatDate } from '@/lib/utils' import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message' import { useCanWrite } from '@/lib/hooks/use-can-write' import type { WebshopOrder, WebshopStoreSettings } from '@/types' const OrderBookingDialog = dynamic(() => import('@/components/orders/OrderBookingDialog'), { ssr: false, }) const CreateInvoiceFromOrderDialog = dynamic( () => import('@/components/orders/CreateInvoiceFromOrderDialog'), { ssr: false }, ) const MarkOrderBookedDialog = dynamic( () => import('@/components/orders/MarkOrderBookedDialog'), { ssr: false }, ) const BulkOrderBookingDialog = dynamic( () => import('@/components/orders/BulkOrderBookingDialog'), { ssr: false }, ) /** * A row can join the bulk sweep exactly when its single-row Bokför button * would render: unbooked, uninvoiced, not marked as booked outside the * integration, and either a refund or a paid order. Legacy-overlap rows stay * selectable on purpose: the server guard decides and the per-order failure * report explains (no dead-end soft guard). */ function isBulkBookable(order: WebshopOrder, canWrite: boolean): boolean { return ( canWrite && order.journal_entry_id === null && order.invoice_id === null && order.manually_booked_at === null && (order.row_type === 'refund' || order.is_paid) ) } interface StoreFacet { platform: string store_scope: string store_label: string | null } type StatusTab = 'all' | 'unpaid' | 'paid' | 'refunds' | 'booked' const PAGE_SIZE = 50 /** Server-side query params per status tab (pagination stays correct). */ function tabQuery(tab: StatusTab): string { switch (tab) { case 'unpaid': return '&paid=unpaid' case 'paid': return '&paid=paid&booked=unbooked' case 'refunds': return '&row_type=refund' case 'booked': return '&booked=booked' default: return '' } } export default function OrdersPage() { const t = useTranslations('webshop_orders') const errorLocale = useLocale() as ErrorLocale const { canWrite } = useCanWrite() const { toast } = useToast() const [rows, setRows] = useState([]) const [stores, setStores] = useState([]) const [settings, setSettings] = useState([]) const [count, setCount] = useState(0) const [loading, setLoading] = useState(true) // A transient fetch failure must not render as "no orders" with a connect // CTA (review finding): failure gets its own retry state. const [loadFailed, setLoadFailed] = useState(false) const [tab, setTab] = useState('all') const [storeScope, setStoreScope] = useState(null) const [page, setPage] = useState(0) const [bookingOrder, setBookingOrder] = useState(null) const [invoicingOrder, setInvoicingOrder] = useState(null) const [markingOrder, setMarkingOrder] = useState(null) const [selectedIds, setSelectedIds] = useState>(new Set()) // Snapshot of the selection the bulk dialog opened with: the list refresh // after a partial failure clears the live selection, but the dialog must // keep showing its per-order report. const [bulkOrders, setBulkOrders] = useState(null) const load = useCallback(async () => { setLoading(true) // A reload changes which rows exist and which are still bookable // (tab/page/store switch, or a completed sweep): start selection over. setSelectedIds(new Set()) try { const storeParam = storeScope ? `&store_scope=${encodeURIComponent(storeScope)}` : '' const res = await fetch( `/api/webshop-orders?limit=${PAGE_SIZE}&offset=${page * PAGE_SIZE}${tabQuery(tab)}${storeParam}`, ) if (!res.ok) throw new Error(`list failed: ${res.status}`) const json = (await res.json()) as { data: WebshopOrder[] count: number | null stores: StoreFacet[] } setRows(json.data) setCount(json.count ?? json.data.length) setStores(json.stores) setLoadFailed(false) } catch { setRows([]) setCount(0) setLoadFailed(true) } finally { setLoading(false) } }, [tab, storeScope, page]) useEffect(() => { void load() }, [load]) useEffect(() => { fetch('/api/webshop-orders/settings') .then((r) => (r.ok ? r.json() : { data: [] })) .then((json: { data: WebshopStoreSettings[] }) => setSettings(json.data ?? [])) .catch(() => setSettings([])) }, []) const visibleRows = rows const multiStore = stores.length > 1 const activeStore = stores.find((s) => s.store_scope === storeScope) ?? null const storeItems = [ { id: 'all', label: t('all_stores') }, ...stores.map((s) => ({ id: s.store_scope, label: s.store_label || s.store_scope })), ] const settingsFor = useCallback( (order: WebshopOrder) => settings.find( (s) => s.platform === order.platform && s.store_scope === order.store_scope, ) ?? null, [settings], ) // Undo a manual "booked outside the integration" mark: no accounting // objects were created, so this simply returns the row to the to-book list. const unmarkOrder = useCallback( async (order: WebshopOrder) => { try { const res = await fetch(`/api/webshop-orders/${order.id}/mark-booked`, { method: 'DELETE', }) const json = await res.json() if (!res.ok || json.error) { toast({ title: t('unmark_failed'), description: getErrorMessage(json, { context: 'transaction', statusCode: res.status, locale: errorLocale, }), variant: 'destructive', }) return } void load() } catch { toast({ title: t('unmark_failed'), variant: 'destructive' }) } }, [load, t, toast, errorLocale], ) const selectableIds = visibleRows .filter((o) => isBulkBookable(o, canWrite)) .map((o) => o.id) const range = useRangeSelect({ visibleIds: selectableIds, selectedIds, setSelectedIds }) const toggleSelect = useCallback( (id: string, extend?: boolean) => range.toggle(id, extend), [range], ) const openBulkBooking = useCallback(() => { setBulkOrders(rows.filter((o) => selectedIds.has(o.id))) }, [rows, selectedIds]) const tabs: Array<{ key: StatusTab; label: string }> = [ { key: 'all', label: t('tab_all') }, { key: 'unpaid', label: t('tab_unpaid') }, { key: 'paid', label: t('tab_to_book') }, { key: 'refunds', label: t('tab_refunds') }, { key: 'booked', label: t('tab_booked') }, ] return (
{tabs.map(({ key, label }) => ( ))}
{ setStoreScope(id === 'all' ? null : id) setPage(0) }} triggerLabel={ activeStore ? activeStore.store_label || activeStore.store_scope : t('all_stores') } ariaLabel={t('store_picker_aria')} />
{loading ? (
) : loadFailed ? (

{t('load_failed')}

) : visibleRows.length === 0 ? ( ) : (
{/* Bulkbar (transactions-page pattern): hidden until at least one bookable order is selected via the hover checkboxes. */} {selectedIds.size > 0 && (
{selectedIds.size}{' '} {t('bulk_selected', { count: selectedIds.size })} {selectedIds.size < selectableIds.length && ( )}
)} {/* Negative margin + matching padding: lets the hover-revealed selection checkbox hang into the page margins without being clipped by the overflow container (transactions-page pattern). */}
{multiStore && } {visibleRows.map((order) => ( setBookingOrder(order)} onInvoice={() => setInvoicingOrder(order)} onMarkBooked={() => setMarkingOrder(order)} onUnmark={() => void unmarkOrder(order)} t={t} /> ))}
{t('col_date')} {t('col_order')}{t('col_store')}{t('col_customer')} {t('col_payment_method')} {t('col_total')} {t('col_status')}
{count > PAGE_SIZE && (
{t('pagination', { from: page * PAGE_SIZE + 1, to: Math.min((page + 1) * PAGE_SIZE, count), total: count, })}
)}
)} {bookingOrder && ( { if (!open) setBookingOrder(null) }} order={bookingOrder} storeSettings={settingsFor(bookingOrder)} onBooked={() => { setBookingOrder(null) void load() }} onSettingsSaved={(updated) => { setSettings((prev) => { const rest = prev.filter( (s) => !(s.platform === updated.platform && s.store_scope === updated.store_scope), ) return [...rest, updated] }) }} /> )} {bulkOrders && ( { if (!open) setBulkOrders(null) }} orders={bulkOrders} settingsFor={settingsFor} onBooked={() => { setSelectedIds(new Set()) void load() }} /> )} {invoicingOrder && ( { if (!open) setInvoicingOrder(null) }} order={invoicingOrder} onCreated={() => { setInvoicingOrder(null) void load() }} /> )} {markingOrder && ( { if (!open) setMarkingOrder(null) }} order={markingOrder} onMarked={() => { setMarkingOrder(null) void load() }} /> )}
) } function OrderRow({ order, multiStore, canWrite, selectable, isSelected, onToggleSelect, onBook, onInvoice, onMarkBooked, onUnmark, t, }: { order: WebshopOrder multiStore: boolean canWrite: boolean selectable: boolean isSelected: boolean onToggleSelect: (id: string, extend?: boolean) => void onBook: () => void onInvoice: () => void onMarkBooked: () => void onUnmark: () => void t: ReturnType> }) { // Radix' onCheckedChange carries no mouse event: the preceding click records // whether shift was held, for range selection. const shiftHeld = useRef(false) const isRefund = order.row_type === 'refund' const booked = order.journal_entry_id !== null const invoiced = order.invoice_id !== null const manuallyMarked = order.manually_booked_at !== null // Cross-marked rows (legacy_transaction_id) keep their action buttons: the // server guard decides (it allows booking once the feed row is booked- // elsewhere-no, ignored-yes) and its 409 message explains what to do. // Hiding the button would be a dead-end soft guard. const bookable = canWrite && !booked && !invoiced && !manuallyMarked && (isRefund || order.is_paid) const invoiceable = canWrite && !isRefund && !booked && !invoiced && !manuallyMarked // Secondary actions live in the overflow menu so the cell keeps one text // button (the two-button layout used to overflow the panel width). const markable = canWrite && !booked && !invoiced && !manuallyMarked const unmarkable = canWrite && manuallyMarked return ( {/* Hover-revealed selection checkbox (transactions-page pattern): zero-width cell, the checkbox hangs in the left page margin so the date column stays where it was. Selected rows keep it visible. */} {selectable && ( { shiftHeld.current = e.shiftKey }} onCheckedChange={() => onToggleSelect(order.id, shiftHeld.current)} aria-label={t('select_order_aria', { number: order.order_number })} className={cn( 'absolute -left-5 top-1/2 -translate-y-1/2 border-foreground duration-150 md:-left-6', isSelected ? 'opacity-100' : CHECKBOX_REVEAL_CLASS, )} /> )} {formatDate(order.order_date)} {isRefund ? ( {t('refund_prefix')} {order.order_number} ) : ( {order.order_number} )} {multiStore && ( {order.store_label || order.store_scope} )} {/* One-line rows (design convention 4): the full beställningsuppgifter (kontaktperson, orgnr, e-post) live in the native tooltip. */} {order.customer_company || order.customer_name || ( {'–'} )} {order.payment_method_title || order.payment_method || ''} {formatCurrency(order.total, order.currency)} {/* One action per row: Bokför when the money event is bookable, else Skapa faktura for unpaid invoice-flow orders. Two buttons side by side pushed the table past the panel width and the overflow clip swallowed single-button cells. */}
{bookable ? ( ) : invoiceable ? ( ) : null} {(markable || unmarkable) && ( {markable && ( {t('action_mark_booked')} )} {unmarkable && ( {t('action_unmark_booked')} )} )}
) } /** * Chips mark exceptions only (design convention 5): booked rows render as * muted text; deviations (unbooked-but-paid, unpaid, legacy overlap, remote * drift) get a Badge. */ function OrderStatus({ order, t, }: { order: WebshopOrder t: ReturnType> }) { if (order.remote_changed_after_freeze) { return {t('status_remote_changed')} } if (order.journal_entry_id) { return {t('status_booked')} } // Marked as handled outside the integration: a normal done state, so muted // text, not a chip (convention 5). Links to the referenced verifikat when // the user picked one. if (order.manually_booked_at) { return order.manually_booked_journal_entry_id ? ( {t('status_marked_booked')} ) : ( {t('status_marked_booked')} ) } if (order.invoice_id) { return ( {t('status_invoiced')} ) } if (order.legacy_transaction_id) { return ( {t('status_in_transactions')} ) } if (!order.is_paid && order.row_type === 'order') { return {t('status_unpaid')} } return {t('status_to_book')} }