# Orders UI — Design Plan (Admin Dashboard)

**Audience:** Senior software engineers
**Scope:** Orders UI only — list page, detail page, all admin actions. No new backend work.
**Backend reference:** `06-order-processing-backend-implementation-plan.md`
**Design constraint:** Match the existing admin design language exactly (ShadCN only, HugeIcons, same layout shell, same patterns as Products page).

---

## 1. Design philosophy

Three principles guide every decision here:

1. **Single responsibility per component.** Each component owns one concern: a card, a table, a button, a timeline. No component manages both data display and action side-effects unless it is a leaf action component.
2. **Status-driven UI.** The order's `status` field is the single source of truth. What you see, what buttons appear, what badges render — all computed from status. No hidden state, no guessing.
3. **Consistency over novelty.** Use the exact same patterns already in the codebase: `rounded-lg border` for table wrappers, `text-xl font-semibold` headings, `usePaginatedQuery` + "Load more", `AlertDialog` for destructive actions, `toast` (Sonner) for feedback.

---

## 2. Route structure

```
/orders                   → OrdersPage        (list)
/orders/[id]              → OrderDetailPage   (detail)
```

The detail page is a full Next.js route (`app/(dashboard)/orders/[id]/page.tsx`), not a dialog. Orders are complex enough to deserve their own page; a dialog would feel cramped.

---

## 3. Status vocabulary

Both pages share the same two badge components. Define color intent once, reuse everywhere.

### 3.1 Order status badges

| Status | Badge variant | Tailwind override | Meaning |
|--------|--------------|-------------------|---------|
| `pending` | `secondary` | — | Payment not yet confirmed |
| `confirmed` | `outline` | `border-blue-500 text-blue-600` | Paid, awaiting fulfilment |
| `processing` | `outline` | `border-violet-500 text-violet-600` | Label created, packing |
| `shipped` | `outline` | `border-indigo-500 text-indigo-600` | In transit |
| `delivered` | `default` | `bg-green-600` | Delivered |
| `cancelled` | `destructive` | — | Cancelled |
| `refunded` | `secondary` | `line-through` | Refunded |

### 3.2 Payment status badges

| Status | Badge variant | Tailwind override |
|--------|--------------|-------------------|
| `pending` | `secondary` | — |
| `paid` | `default` | `bg-green-600` |
| `failed` | `destructive` | — |
| `refunded` | `secondary` | — |

Both mappings live in `apps/admin/src/components/orders/shared/statusConfig.ts` — a plain TypeScript constants file, no JSX.

---

## 4. Orders list page

**File:** `apps/admin/src/app/(dashboard)/orders/page.tsx`

### 4.1 Layout (top to bottom)

```
┌─────────────────────────────────────────────────┐
│  Orders                                          │
├─────────────────────────────────────────────────┤
│  [🔍 Search by order # or email…]  [Status ▾]   │
├─────────────────────────────────────────────────┤
│  ┌─────────────────────────────────────────────┐ │
│  │ Order #  │ Customer │ Date │ Status │ Total │ │
│  │──────────┼──────────┼──────┼────────┼───────│ │
│  │ ORD-AB12 │ Jane Doe │ …    │ [Conf] │ £42   │ │
│  └─────────────────────────────────────────────┘ │
│  12 loaded                      [Load more]      │
└─────────────────────────────────────────────────┘
```

### 4.2 Data fetching

- **Default (no filter, no search):** `usePaginatedQuery(api.orders.listAll, {}, { initialNumItems: 25 })`
- **Status filter active:** `usePaginatedQuery(api.orders.listAll, { status }, { initialNumItems: 25 })`
- Pagination: "Load more" button + count footer — identical to ProductsPage.
- No search query for now (orders list does not have a full-text search index). Search by order number or email is client-side filtered from the loaded page; add a note in the component that server-side search can be added when an index exists.

### 4.3 Table columns

| Column | Notes |
|--------|-------|
| Order # | `font-mono text-xs`; clicking the row navigates to detail |
| Customer | `order.email`; `text-muted-foreground` |
| Date | `createdAt` formatted short; `text-xs text-muted-foreground` |
| Status | `<OrderStatusBadge status={order.status} />` |
| Payment | `<OrderPaymentBadge status={order.paymentStatus} />` |
| Total | `formatPrice(order.total)` right-aligned |

No actions menu column — clicking anywhere on the row navigates to the detail page. Keeps the list clean. A chevron icon on the right of each row communicates navigability.

### 4.4 Extracted components

| Component | File | Responsibility |
|-----------|------|---------------|
| `OrderStatusBadge` | `components/orders/shared/OrderStatusBadge.tsx` | Renders one `<Badge>` for order status |
| `OrderPaymentBadge` | `components/orders/shared/OrderPaymentBadge.tsx` | Renders one `<Badge>` for payment status |
| `OrdersTableSkeleton` | inline in list page | 10 skeleton rows, same columns |

### 4.5 Empty and loading states

- **Loading:** 10 skeleton rows with matching column widths.
- **Empty (no orders):** Centered row: *"No orders yet."*
- **Empty (filter applied):** *"No orders with status 'confirmed'."*

---

## 5. Order detail page

**File:** `apps/admin/src/app/(dashboard)/orders/[id]/page.tsx`

This page uses `useQuery(api.orders.getById, { id })` and `useQuery(api.orders.getTimeline, { orderId: id })` to load both the order and its timeline in parallel.

### 5.1 Layout

Two-column layout on `md+`, stacked on mobile. The left column is wider (about 60%) and holds the primary data. The right column holds contextual cards.

```
┌──────────────────────┬───────────────────┐
│                      │                   │
│  ← Orders            │  Customer         │
│  ORD-AB12  [Conf]    │  Jane Doe         │
│  14 Jan 2026         │  jane@example.com │
│                      ├───────────────────┤
│  [Admin actions bar] │  Shipping address │
│                      │  123 Main St…     │
├──────────────────────┤                   │
│  Order items         ├───────────────────┤
│  ──────────────────  │  Fulfilment       │
│  Product  Qty  Total │  Carrier: DPD UK  │
│  …                   │  Service: Next Day│
├──────────────────────┤  [Create Label]   │
│  Financials          ├───────────────────┤
│  Subtotal  £38.00    │                   │
│  Shipping  £4.99     │  (empty if no     │
│  Total     £42.99    │   tracking yet)   │
├──────────────────────┤                   │
│  Timeline            │                   │
│  ○ Order confirmed   │                   │
│  ○ Label created     │                   │
│  ○ Delivered         │                   │
└──────────────────────┴───────────────────┘
```

On mobile, right column cards stack below the left column, in this order: Customer → Shipping → Fulfilment.

### 5.2 Loading state

All cards render as `Skeleton` blocks while data is `undefined`. Each card has its own skeleton shape so the layout does not shift when data loads. No full-page spinner.

### 5.3 Component breakdown

Every component below has **one job**. None of them fetch data themselves — the page fetches and passes props down.

---

#### 5.3.1 `OrderPageHeader`

**File:** `components/orders/detail/OrderPageHeader.tsx`
**Job:** Back link, order number, date, status badges.

```
← Orders   ORD-AB12   [Confirmed]  [Paid]   14 Jan 2026, 10:22
```

Props: `orderNumber`, `createdAt`, `status`, `paymentStatus`.
No actions here. Badges are read-only. Headings use `text-xl font-semibold`.

---

#### 5.3.2 `OrderActionsBar`

**File:** `components/orders/detail/OrderActionsBar.tsx`
**Job:** Render the correct set of action buttons based on the order's current status. This is the only component that "knows" which actions are valid — it reads `status`, `paymentStatus`, `trackingNumber`, `returnRequestedAt`, `returnReceivedAt` and decides what to render.

Action matrix:

| Condition | Buttons shown |
|-----------|--------------|
| `status === "confirmed"` and no `trackingNumber` | **Create Label** (primary), **Update Status** (outline) |
| `status === "confirmed"` and `trackingNumber` exists | **Update Status** (outline) |
| `status === "confirmed"` and `returnRequestedAt` and no `returnReceivedAt` | **Mark Return Received** (outline), **Update Status** |
| `status === "confirmed"` and `returnReceivedAt` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
| `status === "processing"` or `"shipped"` | **Update Status** only |
| `status === "delivered"` and `paymentStatus !== "refunded"` | **Issue Refund** (destructive), **Update Status** |
| `status === "cancelled"` or `"refunded"` | Read-only — no buttons |

**`OrderActionsBar` does not implement the action logic itself.** It renders the individual action components below. It is purely a conditional layout shell.

---

#### 5.3.3 `CreateLabelButton`

**File:** `components/orders/actions/CreateLabelButton.tsx`
**Job:** Call `api.fulfillmentActions.createShippingLabel`, show loading spinner, surface structured errors.

- Uses `useAction(api.fulfillmentActions.createShippingLabel)`.
- On click: sets loading, calls action, on success shows `toast.success("Label created. Tracking: {trackingNumber}")`, on structured error shows `toast.error(result.message)`, on thrown error shows generic toast.
- Button label: "Create Label" → "Creating…" while loading.
- Disabled while loading.
- No AlertDialog — creating a label is not destructive.

---

#### 5.3.4 `UpdateStatusDialog`

**File:** `components/orders/actions/UpdateStatusDialog.tsx`
**Job:** A `Dialog` containing a `Select` of valid next statuses. On confirm, calls `api.orders.updateStatus`.

- Triggered by "Update Status" button in `OrderActionsBar`.
- Select shows all statuses except the current one.
- Submit button shows spinner while mutating.
- On success: `toast.success("Status updated to {status}")`.

---

#### 5.3.5 `MarkReturnReceivedButton`

**File:** `components/orders/actions/MarkReturnReceivedButton.tsx`
**Job:** Call `api.orders.markReturnReceived` with an `AlertDialog` confirmation.

- AlertDialog title: *"Mark return as received?"*
- Description: *"Confirm that the returned items have arrived. You can then issue a refund."*
- Uses `useMutation(api.orders.markReturnReceived)`.
- Shows spinner on the confirm button while loading.

---

#### 5.3.6 `IssueRefundButton`

**File:** `components/orders/actions/IssueRefundButton.tsx`
**Job:** Call `api.returnActions.issueRefund` with an `AlertDialog` confirmation.

- AlertDialog title: *"Issue full refund?"*
- Description: *"A full refund of £{order.total} will be sent to the customer via Stripe. This cannot be undone."*
- Uses `useAction(api.returnActions.issueRefund)`.
- Confirm button: destructive variant, "Issue Refund" → "Refunding…".
- On success: `toast.success("Refund issued")`.
- On error: `toast.error(err.message)`.

---

#### 5.3.7 `OrderItemsCard`

**File:** `components/orders/detail/OrderItemsCard.tsx`
**Job:** Display the line items in a `Card` with a bordered table inside.

Columns: Product name (+ variant name below in `text-xs text-muted-foreground`), SKU (`font-mono text-xs`), Qty, Unit price, Total.
No actions. Read-only.

```
┌─ Order items ────────────────────────────────────┐
│ Product          SKU        Qty  Unit    Total   │
│ Royal Canin…                                     │
│   Adult 2kg      SKU-001     2   £19.00  £38.00  │
└──────────────────────────────────────────────────┘
```

---

#### 5.3.8 `OrderFinancialsCard`

**File:** `components/orders/detail/OrderFinancialsCard.tsx`
**Job:** Render the price breakdown.

Uses `InfoRow`-style layout (label left, value right) inside a `Card`. Subtotal, shipping, discount (if > 0), tax (if > 0), and a `Separator` before the bold total.

---

#### 5.3.9 `CustomerCard`

**File:** `components/orders/detail/CustomerCard.tsx`
**Job:** Display the customer name and email.

`Card` with `CardHeader` (title "Customer") and `CardContent`. Email is a `mailto:` link styled as `text-sm text-muted-foreground hover:underline`.

---

#### 5.3.10 `ShippingAddressCard`

**File:** `components/orders/detail/ShippingAddressCard.tsx`
**Job:** Render the `shippingAddressSnapshot` in a `Card`.

Displays each address line in `text-sm`, country in `text-xs text-muted-foreground`. No editing — it's a snapshot.

---

#### 5.3.11 `FulfilmentCard`

**File:** `components/orders/detail/FulfilmentCard.tsx`
**Job:** Shipping service info + tracking state. No action buttons — those live in `OrderActionsBar`.

Three visual states:

**State A — No label yet (status: `confirmed`, no `trackingNumber`):**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier        DPD UK                         │
│ Service        Next Day                       │
│ Method         Express                        │
│                                               │
│ No label created yet.                         │
└───────────────────────────────────────────────┘
```

**State B — Label created, in transit:**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier        DPD UK                         │
│ Service        Next Day                       │
│ Tracking       1Z999AA1…   [↗ Track]          │
│ Status         TRANSIT                        │
│ Est. delivery  18 Jan 2026                    │
│ Label          [↗ Download]                   │
└───────────────────────────────────────────────┘
```

**State C — Delivered:**
```
┌─ Fulfilment ──────────────────────────────────┐
│ Carrier        DPD UK                         │
│ Tracking       1Z999AA1…   [↗ Track]          │
│ Status         DELIVERED                      │
│ Delivered      17 Jan 2026, 14:35             │
└───────────────────────────────────────────────┘
```

The `trackingStatus` field drives the displayed status string. Tracking and label URLs open in a new tab.

---

#### 5.3.12 `OrderTimelineCard`

**File:** `components/orders/detail/OrderTimelineCard.tsx`
**Job:** Render `orderTimelineEvents` as a vertical stepper/timeline.

Receives the `events` array as a prop (fetched by the page).

Each event row:
```
○  Status changed: confirmed → processing     14 Jan · 10:35
   by admin
```

Event type → human label mapping:

| `eventType` | Display label |
|-------------|--------------|
| `status_change` | "Status changed: {fromStatus} → {toStatus}" |
| `label_created` | "Shipping label created" + tracking number from payload |
| `tracking_update` | "Tracking update: {status}" |
| `customer_cancel` | "Customer requested cancellation" |
| `return_requested` | "Customer requested return" |
| `return_received` | "Return marked as received" |
| `refund` | "Refund issued" |

The dot (`○`) color matches the status badge color for `status_change` events; neutral for all others.

Loading state: 3–4 skeleton rows.
Empty state: *"No activity yet."*

---

## 6. Shared utilities

**File:** `components/orders/shared/statusConfig.ts`

```typescript
// Order status → { label, badgeVariant, className }
// Payment status → { label, badgeVariant, className }
// Event type → human label
// Timeline dot color by toStatus
```

Keep all display constants here. Both pages and all badge components import from this single file. No duplication.

---

## 7. File tree

```
apps/admin/src/
├── app/(dashboard)/
│   └── orders/
│       ├── page.tsx                        ← Orders list page
│       └── [id]/
│           └── page.tsx                    ← Order detail page
└── components/
    └── orders/
        ├── shared/
        │   ├── statusConfig.ts             ← Constants only, no JSX
        │   ├── OrderStatusBadge.tsx
        │   └── OrderPaymentBadge.tsx
        ├── detail/
        │   ├── OrderPageHeader.tsx
        │   ├── OrderActionsBar.tsx         ← Layout shell, no action logic
        │   ├── OrderItemsCard.tsx
        │   ├── OrderFinancialsCard.tsx
        │   ├── CustomerCard.tsx
        │   ├── ShippingAddressCard.tsx
        │   ├── FulfilmentCard.tsx
        │   └── OrderTimelineCard.tsx
        └── actions/
            ├── CreateLabelButton.tsx
            ├── UpdateStatusDialog.tsx
            ├── MarkReturnReceivedButton.tsx
            └── IssueRefundButton.tsx
```

---

## 8. Data flow

```
OrderDetailPage (page.tsx)
│
│  useQuery(api.orders.getById, { id })       → order
│  useQuery(api.orders.getTimeline, { orderId }) → events
│
├── OrderPageHeader          (order)
├── OrderActionsBar          (order)
│   ├── CreateLabelButton    (orderId, order.status, order.trackingNumber)
│   ├── UpdateStatusDialog   (orderId, order.status)
│   ├── MarkReturnReceivedButton (orderId)
│   └── IssueRefundButton    (orderId, order.total, order.currency)
│
├── [left column]
│   ├── OrderItemsCard       (order.items)
│   ├── OrderFinancialsCard  (order)
│   └── OrderTimelineCard    (events)
│
└── [right column]
    ├── CustomerCard         (order.email, order.userId)
    ├── ShippingAddressCard  (order.shippingAddressSnapshot)
    └── FulfilmentCard       (order)
```

All queries are read-only in the page. Action components call mutations/actions themselves and handle their own loading/error state. The page never passes mutation functions as props.

---

## 9. Implementation order

| Step | What to build | Why first |
|------|---------------|-----------|
| 1 | `statusConfig.ts`, `OrderStatusBadge`, `OrderPaymentBadge` | Shared atoms used by both pages |
| 2 | Orders list page | Validates data fetching works, gives navigation to detail |
| 3 | `OrderPageHeader`, `OrderItemsCard`, `OrderFinancialsCard` | Core detail page scaffold — read-only |
| 4 | `CustomerCard`, `ShippingAddressCard`, `FulfilmentCard` | Right column — all read-only |
| 5 | `OrderTimelineCard` | Needs timeline query wired |
| 6 | `UpdateStatusDialog` | Lowest-risk action — any status |
| 7 | `CreateLabelButton` | Most impactful Phase 1 action |
| 8 | `MarkReturnReceivedButton`, `IssueRefundButton` | Phase 3/4 actions |
| 9 | `OrderActionsBar` | Wire all actions together with condition logic |

Build read-only views first, add write actions last. This way the detail page is useful even before all actions are wired.

---

## 10. Design decisions and rationale

**Why a full page for detail, not a dialog?**
Orders have 5+ cards, a timeline, and multiple action flows. A dialog forces vertical scrolling with no space for the two-column layout. A page also makes deep-linking (e.g. linking from a customer email) trivial.

**Why does `OrderActionsBar` not implement actions?**
If it did, adding or changing one action would require modifying a large, complex component. With leaf action components, each action is independently testable and replaceable.

**Why no search on the list page?**
The `orders` table has no full-text search index. Client-side filtering over 25 loaded rows is fine for a small operation, but it does not scale past the first page. A future iteration can add a `by_order_number` or `by_email` query when the need is clear.

**Why `useAction` for `createShippingLabel` and `issueRefund`?**
Both are Convex actions (they call external APIs). `useAction` from `convex/react` is the correct hook — not `useMutation`.

**Why show `trackingStatus` in `FulfilmentCard` rather than a separate card?**
Tracking info and fulfilment info are tightly related; an admin needs them together. Separating them would force eye-scanning across two cards to understand the delivery state.

---

*Document version: 1.0. UI only — no new backend work required.*
