Files
accounted/app/(dashboard)/orders/page.tsx
T
MattssonandClaude Fable 5 c35b2547fb feat(webshop-orders): Orders page with per-store, per-payment-method booking (#1525)
* feat(webshop-orders): schema, types and error codes for the orders surface

webshop_orders (order/refund rows, financial-freeze trigger, member
select/update RLS, no DELETE) + webshop_store_settings (per-store payment
method -> account map), source_type 'webshop_order', multi-store index drop,
customer_country, and a one-time woo cursor reset so the switch-over
backfills and cross-marks existing feed rows. Tables classified in the
full-archive export; pg-real coverage for RLS, freeze and CHECK.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): core service (ingest, booking lines)

upsertWebshopOrders: two-phase order/refund upsert with FX enrichment,
legacy-feed cross-marking, frozen-row protection and field-wise jsonb
comparisons (Postgres does not preserve object key order). Booking-line
builder: per-rate VAT split with SIGNED buckets (discounts book as revenue
reductions), refund mirroring, 3740 residual, per-store account prefill,
and advisory export/EU + OSS warnings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): API routes for list, booking, invoicing and mapping

Booking is draft -> atomic claim -> commit (conditional link-back closes the
concurrent double-book race; a lost claim cancels the voucher-free draft).
Legacy-feed guard honors transactions.is_ignored on both the book and
create-invoice paths. Invoice conversion reuses buildInvoiceWriteData for an
unnumbered draft with dominant-rate fallback and drift-safe unit prices.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(webshop-orders): Orders page, booking/invoice dialogs and gated nav

/orders lists per-store orders with status tabs (server-side filters),
exception chips and one action per row. Booking dialog prefills from the
per-store payment-method mapping with an opt-in remember; invoice dialog
converts to a draft kundfaktura. The Order nav item renders only for
companies with an active WooCommerce connection or existing order rows
(Shopify deliberately excluded until its sync writes webshop_orders).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(woocommerce): switch the order sync to webshop_orders, multi-store

The sync maps rich wc/v3 payloads (billing, line/shipping/fee taxes, refund
allocations with parent-prorated VAT fallback) and upserts order rows
instead of transactions-inbox rows; already-imported feed rows stay
bookable and get cross-marked. Multi-store: several active connections per
company, per-store panel cards with the account-mapping editor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(webshop-orders): decision log entries and ratchet baseline

Baseline moves DOWN only: naive-ore-round 638 -> 637 via roundOre adoption;
hand-rolled invariants stay at 115 (ACCOUNT_NUMBER_RE imported, not inlined).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(webshop-orders): resolve PR #1525 review findings and CI failures

Review batch (Superagent, CodeRabbit, Swedish compliance review):
- Mutual-exclusion claims: booking guards invoice_id, invoice link-back
  guards journal_entry_id AND treats zero matched rows as the conflict it
  is (409 + rollback), closing both TOCTOU races.
- Freeze v2 migration (20260812124858): the link columns themselves are
  protected: invoice links immutable, journal links clearable only while
  the entry is still a draft (the booking rollback path).
- Scraped orgnr no longer auto-written to customers.org_number; rate
  fallback applies only on single-VAT-bucket orders; refunds get their own
  WEBSHOP_ORDER_REFUND_NOT_CONVERTIBLE code; VAT advisories outrank the
  invoice-mode hint in the booking dialog.
- Ingest compares every synced field (billing corrections no longer drop
  as unchanged); sync guards absent refunds arrays; /sync aggregates
  per-store results; panel disables all cards while a request runs; orders
  page separates load failure from empty; account field explains itself.

CI: regenerated skills/accounted-api; pg tests restructured for
transaction-abort/rollback semantics + freeze-link coverage; unresolvable-
expression ceiling 375 -> 378 with documented reason (partial-update
payloads in ingest, shapes covered by unit tests).

Declined: CodeRabbit docstring-coverage advisory (house style: comments
only where the code cannot say it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 15:08:13 +02:00

424 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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<WebshopOrder[]>([])
const [stores, setStores] = useState<StoreFacet[]>([])
const [settings, setSettings] = useState<WebshopStoreSettings[]>([])
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<StatusTab>('all')
const [storeScope, setStoreScope] = useState<string | null>(null)
const [page, setPage] = useState(0)
const [bookingOrder, setBookingOrder] = useState<WebshopOrder | null>(null)
const [invoicingOrder, setInvoicingOrder] = useState<WebshopOrder | null>(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 (
<div className="space-y-8">
<PageHeader title={t('title')} />
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-1">
{tabs.map(({ key, label }) => (
<button
key={key}
type="button"
onClick={() => {
setTab(key)
setPage(0)
}}
className={cn(
'rounded-full px-3 py-[5px] text-[13px] transition-colors duration-150',
tab === key
? 'bg-secondary text-foreground'
: 'text-muted-foreground hover:bg-secondary/60 hover:text-foreground',
)}
>
{label}
</button>
))}
</div>
<div className="ml-auto">
<ContextPicker
items={storeItems}
value={storeScope ?? 'all'}
onChange={(id) => {
setStoreScope(id === 'all' ? null : id)
setPage(0)
}}
triggerLabel={
activeStore ? activeStore.store_label || activeStore.store_scope : t('all_stores')
}
ariaLabel={t('store_picker_aria')}
/>
</div>
</div>
{loading ? (
<div className="space-y-2">
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
<Skeleton className="h-9 w-full" />
</div>
) : loadFailed ? (
<div className="space-y-4 py-8 text-center">
<p className="text-sm text-muted-foreground">{t('load_failed')}</p>
<Button variant="outline" size="sm" onClick={() => void load()}>
{t('retry')}
</Button>
</div>
) : visibleRows.length === 0 ? (
<EmptyState
icon={ShoppingCart}
title={t('empty_title')}
description={t('empty_description')}
actionLabel={t('empty_action')}
actionHref="/import?mode=woocommerce"
/>
) : (
<div className="stagger-enter overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={TH_CLASS}>{t('col_date')}</th>
<th className={TH_CLASS}>{t('col_order')}</th>
{multiStore && <th className={TH_CLASS}>{t('col_store')}</th>}
<th className={TH_CLASS}>{t('col_customer')}</th>
<th className={TH_CLASS}>{t('col_payment_method')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_total')}</th>
<th className={TH_CLASS}>{t('col_status')}</th>
<th className={cn(TH_CLASS, 'w-0')} />
</tr>
</thead>
<tbody>
{visibleRows.map((order) => (
<OrderRow
key={order.id}
order={order}
multiStore={multiStore}
canWrite={canWrite}
onBook={() => setBookingOrder(order)}
onInvoice={() => setInvoicingOrder(order)}
t={t}
/>
))}
</tbody>
</table>
{count > PAGE_SIZE && (
<div className="mt-4 flex items-center justify-between text-[12.5px] text-muted-foreground">
<span>
{t('pagination', {
from: page * PAGE_SIZE + 1,
to: Math.min((page + 1) * PAGE_SIZE, count),
total: count,
})}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
{t('prev')}
</Button>
<Button
variant="outline"
size="sm"
disabled={(page + 1) * PAGE_SIZE >= count}
onClick={() => setPage((p) => p + 1)}
>
{t('next')}
</Button>
</div>
</div>
)}
</div>
)}
{bookingOrder && (
<OrderBookingDialog
open={!!bookingOrder}
onOpenChange={(open) => {
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 && (
<CreateInvoiceFromOrderDialog
open={!!invoicingOrder}
onOpenChange={(open) => {
if (!open) setInvoicingOrder(null)
}}
order={invoicingOrder}
onCreated={() => {
setInvoicingOrder(null)
void load()
}}
/>
)}
</div>
)
}
function OrderRow({
order,
multiStore,
canWrite,
onBook,
onInvoice,
t,
}: {
order: WebshopOrder
multiStore: boolean
canWrite: boolean
onBook: () => void
onInvoice: () => void
t: ReturnType<typeof useTranslations<'webshop_orders'>>
}) {
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 (
<tr className="group transition-colors duration-150 hover:bg-secondary/35">
<td className={cn(TD_CLASS, 'whitespace-nowrap tabular-nums text-muted-foreground')}>
{formatDate(order.order_date)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
{isRefund ? (
<span className="text-muted-foreground">
{t('refund_prefix')} {order.order_number}
</span>
) : (
<span>{order.order_number}</span>
)}
</td>
{multiStore && (
<td className={cn(TD_CLASS, 'max-w-[160px] truncate text-muted-foreground')}>
{order.store_label || order.store_scope}
</td>
)}
{/* One-line rows (design convention 4): the full beställningsuppgifter
(kontaktperson, orgnr, e-post) live in the native tooltip. */}
<td
className={cn(TD_CLASS, 'max-w-[220px] truncate')}
title={
[
order.customer_company ? order.customer_name : null,
order.customer_orgnr,
order.customer_email,
]
.filter(Boolean)
.join(' · ') || undefined
}
>
{order.customer_company || order.customer_name || (
<span className="text-muted-foreground">{'–'}</span>
)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-muted-foreground')}>
{order.payment_method_title || order.payment_method || ''}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right tabular-nums')}>
{formatCurrency(order.total, order.currency)}
</td>
<td className={cn(TD_CLASS, 'whitespace-nowrap')}>
<OrderStatus order={order} t={t} />
</td>
{/* 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. */}
<td className={cn(TD_CLASS, 'whitespace-nowrap text-right')}>
{bookable ? (
<Button variant="outline" size="sm" onClick={onBook}>
{t('action_book')}
</Button>
) : invoiceable ? (
<Button variant="outline" size="sm" onClick={onInvoice}>
{t('action_create_invoice')}
</Button>
) : null}
</td>
</tr>
)
}
/**
* 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<typeof useTranslations<'webshop_orders'>>
}) {
if (order.remote_changed_after_freeze) {
return <Badge variant="warning">{t('status_remote_changed')}</Badge>
}
if (order.journal_entry_id) {
return <span className="text-xs text-muted-foreground">{t('status_booked')}</span>
}
if (order.invoice_id) {
return (
<Link
href={`/invoices/${order.invoice_id}`}
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 hover:text-foreground"
>
{t('status_invoiced')}
</Link>
)
}
if (order.legacy_transaction_id) {
return (
<Link href="/transactions">
<Badge variant="outline">{t('status_in_transactions')}</Badge>
</Link>
)
}
if (!order.is_paid && order.row_type === 'order') {
return <Badge variant="secondary">{t('status_unpaid')}</Badge>
}
return <Badge variant="warning">{t('status_to_book')}</Badge>
}