'use client' import { useCallback, useEffect, useState } from 'react' import dynamic from 'next/dynamic' import Link from 'next/link' import { useTranslations } from 'next-intl' import { ShoppingCart } from 'lucide-react' import { PageHeader } from '@/components/ui/page-header' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { EmptyState } from '@/components/ui/empty-state' import { Skeleton } from '@/components/ui/skeleton' import { ContextPicker } from '@/components/common/ContextPicker' import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table' import { cn, formatCurrency, formatDate } from '@/lib/utils' 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 }, ) 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 { canWrite } = useCanWrite() 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 load = useCallback(async () => { setLoading(true) 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], ) 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 ? ( ) : (
{multiStore && } {visibleRows.map((order) => ( setBookingOrder(order)} onInvoice={() => setInvoicingOrder(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] }) }} /> )} {invoicingOrder && ( { if (!open) setInvoicingOrder(null) }} order={invoicingOrder} onCreated={() => { setInvoicingOrder(null) void load() }} /> )}
) } function OrderRow({ order, multiStore, canWrite, onBook, onInvoice, t, }: { order: WebshopOrder multiStore: boolean canWrite: boolean onBook: () => void onInvoice: () => void t: ReturnType> }) { const isRefund = order.row_type === 'refund' const booked = order.journal_entry_id !== null const invoiced = order.invoice_id !== 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 && (isRefund || order.is_paid) const invoiceable = canWrite && !isRefund && !booked && !invoiced return ( {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} ) } /** * 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')} } 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')} }