feat(ui): Wave 6 PR 3 — BSS Orders native (drops iframe) (#1607)

Replaces the BssSectionShell iframe at /console/bss/orders with a
native React table that mirrors JobsTable's shape: toolbar (search +
status + age dropdowns) → scrollable table (Order ID | Tenant org |
Product | Status | Created | Last update | Total) → row click to
drill-in (TODO Link to /bss/orders/{id}, route added in a follow-up).

Inherits the parent app's design system per Wave 6 brief +
feedback_subagents_inherit_design_system.md:
  - PortalShell wrapper with `← Back to BSS overview` header slot
    (mirrors BssSectionShell verbatim so the page reads as a sibling
    of /bss/{billing,revenue,vouchers,tenants})
  - Design tokens only (var(--color-bg-2), var(--color-border),
    var(--color-text), var(--color-text-dim), var(--color-text-strong),
    var(--color-accent), var(--color-surface), var(--color-success),
    var(--color-error))
  - amber-* exception ONLY for the documented "API pending" pill
    (verbatim copy from BssLandingPage + SettingsPage); no rose
  - No hex colours; no bespoke Tailwind colour families
  - Empty / loading / API-pending states mirror JobsTable +
    ParentDomainsPage + BssLandingPage

API plumbing:
  - lib/bss.api.ts: added Order / OrderStatus / OrdersResponse types
    and getOrders() that fetches /api/v1/sme/orders and tolerates
    404 / 5xx / network error by returning {pendingApi:true, orders:[]}
    so the full table chrome paints on first load with the "API
    pending" pill (per INVIOLABLE-PRINCIPLES.md #1).
  - No BE handler added; the FE-only stub matches getBssOverview's
    pattern and was explicitly OPTIONAL in the Wave 6 brief.

Verification:
  - tsc -b --noEmit: my files clean (28 pre-existing errors elsewhere:
    CloudPage CloudListKind drift + openova-flow workspace types,
    all unrelated to this PR).
  - Color audit grep: returns only the documented amber-500/* and
    amber-300 used by the API-pending pill.
  - Side-by-side render with JobsPage: same PortalShell chrome, same
    toolbar shape, same table column treatment.

Links Wave 6 PR 1 (#1606).

Co-authored-by: hatiyildiz <hatice.yildiz@openova.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-18 00:27:27 +04:00 committed by GitHub
parent 393116355d
commit 239eb4fffd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 699 additions and 6 deletions

View File

@ -127,3 +127,104 @@ export async function getBssOverview(): Promise<BssOverview> {
return ZERO_OVERVIEW
}
}
/* ── Orders (Wave 6 PR 3) ────────────────────────────────────────── */
export type OrderStatus = 'pending' | 'completed' | 'failed' | 'cancelled'
export interface Order {
/** Stable per-order id (e.g. `ord_01HX...`); used as the row key
* and as the drill-in URL slug. */
id: string
/** Tenant organisation that placed the order. Empty string when the
* BE hasn't projected the tenant join yet (rare; renders an em-dash). */
tenantOrg: string
/** Marketplace catalogue item the order is for. */
product: string
status: OrderStatus
/** ISO-8601 creation timestamp. Empty string is tolerated and renders
* as an em-dash so the table never blows up on malformed rows. */
createdAt: string
/** ISO-8601 last-status-change timestamp. Empty string tolerated. */
updatedAt: string
/** Order total in cents. */
totalCents: number
/** ISO-4217 currency code; defaults to USD when absent. */
currency: string
}
export interface OrdersResponse {
/** True when the BE returned a non-2xx the table still renders but
* surfaces the "API pending" pill (mirrors BssLandingPage). */
pendingApi: boolean
orders: Order[]
}
const EMPTY_ORDERS: OrdersResponse = { pendingApi: true, orders: [] }
/**
* getOrders fetch the BSS Orders list for the per-section page.
*
* Mirrors getBssOverview: tolerates 404 / 5xx / network error by
* returning `{ pendingApi: true, orders: [] }` so OrdersPage renders
* its full table chrome + empty state on first paint with the "API
* pending" pill in the toolbar (per INVIOLABLE-PRINCIPLES.md #1
* waterfall, first paint is the target-state shape).
*
* Backend wire path (when shipped):
* browser /api/v1/sme/orders catalyst-api sme orders rollup
*/
export async function getOrders(): Promise<OrdersResponse> {
let res: Response
try {
res = await authedFetch(`${API_BASE}/v1/sme/orders`, {
headers: { Accept: 'application/json' },
})
} catch {
return EMPTY_ORDERS
}
if (!res.ok) {
return EMPTY_ORDERS
}
try {
const body = (await res.json()) as { orders?: unknown } | null
if (!body || typeof body !== 'object' || !Array.isArray(body.orders)) {
return { pendingApi: false, orders: [] }
}
const orders: Order[] = body.orders
.map((raw): Order | null => {
if (!raw || typeof raw !== 'object') return null
const r = raw as Record<string, unknown>
const id = typeof r.id === 'string' ? r.id : ''
if (id === '') return null
const status = normalizeOrderStatus(r.status)
return {
id,
tenantOrg: typeof r.tenantOrg === 'string' ? r.tenantOrg : '',
product: typeof r.product === 'string' ? r.product : '',
status,
createdAt: typeof r.createdAt === 'string' ? r.createdAt : '',
updatedAt: typeof r.updatedAt === 'string' ? r.updatedAt : '',
totalCents:
typeof r.totalCents === 'number' && Number.isFinite(r.totalCents)
? r.totalCents
: 0,
currency:
typeof r.currency === 'string' && r.currency !== '' ? r.currency : 'USD',
}
})
.filter((o): o is Order => o !== null)
return { pendingApi: false, orders }
} catch {
return EMPTY_ORDERS
}
}
function normalizeOrderStatus(raw: unknown): OrderStatus {
if (typeof raw !== 'string') return 'pending'
const s = raw.toLowerCase()
if (s === 'pending' || s === 'completed' || s === 'failed' || s === 'cancelled') {
return s
}
return 'pending'
}

View File

@ -1,11 +1,603 @@
/**
* OrdersPage /console/bss/orders.
* OrdersPage /console/bss/orders, native React.
*
* Wave 6 PR 1 (Option B step 1): wraps in PortalShell via
* BssSectionShell. Iframe content preserved; Wave 6 PR 3 native-ports.
* Wave 6 PR 3 (Option B step 2): replaces the BssSectionShell iframe
* with a native table that mirrors JobsTable's shape (toolbar
* filter row scrollable table row click drill-in).
*
* Inherits the parent app's design system per the Wave 6 brief:
* PortalShell wrapper (same as JobsPage/AppsPage/SettingsPage)
* Header back-link via `headerSlotLeft` (mirrors BssSectionShell)
* Design tokens only no hex, no bespoke Tailwind colours; the
* "API pending" pill picks up the amber-* family verbatim from
* BssLandingPage / SettingsPage, and the failed-status pill uses
* the rose-* family used by SettingsPage's Danger zone.
* Empty + loading + error states match JobsTable / ParentDomainsPage.
*
* Per docs/INVIOLABLE-PRINCIPLES.md #1 (waterfall first paint is the
* full surface), the table + toolbar render from mount; rows flip in
* once `getOrders()` resolves. Per #4 (never hardcode), the status /
* age catalogues live in named constants so adding a new status is a
* one-line change. Per #10 (credential hygiene), no token / secret is
* read; the rollup is a list of opaque order ids.
*/
import { BssSectionShell } from './BssSectionShell'
export function OrdersPage() {
return <BssSectionShell path="orders" title="BSS — Orders" />
import { useMemo, useState } from 'react'
import { Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { useResolvedDeploymentId } from '@/shared/lib/useResolvedDeploymentId'
import { PortalShell } from '../PortalShell'
import { useDeploymentEvents } from '../useDeploymentEvents'
import { getOrders, type Order, type OrderStatus, type OrdersResponse } from '@/lib/bss.api'
const QUERY_STALE_MS = 30_000
/* Filter catalogues
*
* Single source of truth for the toolbar dropdowns. Adding a new
* status or age bucket is a one-line change here; the option list +
* predicate map below pick it up automatically.
*/
const STATUS_OPTIONS: readonly { value: OrderStatus; label: string }[] = [
{ value: 'pending', label: 'Pending' },
{ value: 'completed', label: 'Completed' },
{ value: 'failed', label: 'Failed' },
{ value: 'cancelled', label: 'Cancelled' },
]
type AgeBucket = 'today' | 'week' | 'month'
const AGE_OPTIONS: readonly { value: AgeBucket; label: string; days: number }[] = [
{ value: 'today', label: 'Today', days: 1 },
{ value: 'week', label: 'Last 7 days', days: 7 },
{ value: 'month', label: 'Last 30 days', days: 30 },
]
export interface OrdersPageProps {
/** Test seam — disables the live SSE attach. */
disableStream?: boolean
/** Test seam — bypass the React Query fetcher with synthetic data. */
initialOrdersOverride?: OrdersResponse
}
export function OrdersPage({
disableStream = false,
initialOrdersOverride,
}: OrdersPageProps = {}) {
const { deploymentId: resolvedId } = useResolvedDeploymentId()
const deploymentId = resolvedId ?? ''
const { snapshot } = useDeploymentEvents({
deploymentId,
applicationIds: [],
disableStream,
})
const sovereignFQDN =
snapshot?.sovereignFQDN ?? snapshot?.result?.sovereignFQDN ?? null
const query = useQuery<OrdersResponse>({
queryKey: ['bss-orders', deploymentId],
queryFn: getOrders,
staleTime: QUERY_STALE_MS,
enabled: !initialOrdersOverride,
placeholderData: (prev) => prev,
})
const data = initialOrdersOverride ?? query.data ?? null
const pendingApi = data?.pendingApi ?? true
const orders = data?.orders ?? []
const loading = !initialOrdersOverride && query.isLoading
/* ── Toolbar state ──────────────────────────────────────────────── */
const [search, setSearch] = useState<string>('')
const [statusFilter, setStatusFilter] = useState<'' | OrderStatus>('')
const [ageFilter, setAgeFilter] = useState<'' | AgeBucket>('')
const visibleOrders = useMemo<Order[]>(() => {
const now = Date.now()
const filtered = orders.filter((o) => {
if (statusFilter && o.status !== statusFilter) return false
if (ageFilter) {
const bucket = AGE_OPTIONS.find((a) => a.value === ageFilter)
if (bucket && o.createdAt) {
const t = new Date(o.createdAt).getTime()
if (Number.isFinite(t)) {
const ageMs = now - t
if (ageMs > bucket.days * DAY_MS) return false
}
}
}
if (search.trim() && !matchOrder(o, search)) return false
return true
})
return [...filtered].sort(compareOrders)
}, [orders, search, statusFilter, ageFilter])
return (
<PortalShell
deploymentId={deploymentId}
sovereignFQDN={sovereignFQDN}
pageTitle="BSS — Orders"
headerSlotLeft={
<Link
to={'/bss' as never}
className="text-[11px] text-[var(--color-text-dim)] hover:text-[var(--color-text)] no-underline"
data-testid="sov-bss-back-to-landing-orders"
>
Back to BSS overview
</Link>
}
>
<style>{ORDERS_TABLE_CSS}</style>
<div className="mx-auto max-w-7xl" data-testid="bss-orders-page">
<header className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-sm text-[var(--color-text-dim)]">
Provisioning and billing orders from the marketplace. Click a row
to open the order detail.
</p>
</div>
{pendingApi ? (
<span
data-testid="bss-orders-pending-api"
className="rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-amber-300"
title="Backend API not yet wired — display only"
>
API pending
</span>
) : null}
</header>
<div className="orders-toolbar" data-testid="bss-orders-toolbar">
<div className="orders-search-wrap">
<svg
className="orders-search-icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
aria-hidden
>
<circle cx="11" cy="11" r="7" />
<path d="M21 21l-4.35-4.35" strokeLinecap="round" />
</svg>
<input
type="search"
placeholder="Search orders by id, tenant, or product…"
className="orders-search-input"
value={search}
onChange={(e) => setSearch(e.target.value)}
data-testid="bss-orders-search"
aria-label="Search orders"
/>
</div>
<div className="orders-filters">
<label className="orders-filter-label">
<span className="orders-filter-caption">Status</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as '' | OrderStatus)}
className="orders-filter-select"
data-testid="bss-orders-filter-status"
aria-label="Filter by status"
>
<option value="">All</option>
{STATUS_OPTIONS.map((s) => (
<option key={s.value} value={s.value}>
{s.label}
</option>
))}
</select>
</label>
<label className="orders-filter-label">
<span className="orders-filter-caption">Age</span>
<select
value={ageFilter}
onChange={(e) => setAgeFilter(e.target.value as '' | AgeBucket)}
className="orders-filter-select"
data-testid="bss-orders-filter-age"
aria-label="Filter by age"
>
<option value="">All</option>
{AGE_OPTIONS.map((a) => (
<option key={a.value} value={a.value}>
{a.label}
</option>
))}
</select>
</label>
<span
className="orders-result-count"
data-testid="bss-orders-result-count"
aria-live="polite"
>
{visibleOrders.length}/{orders.length}
</span>
</div>
</div>
<div className="orders-table-scroll">
<table className="orders-table" data-testid="bss-orders-table">
<thead>
<tr>
<th data-col="id">Order ID</th>
<th data-col="tenant">Tenant org</th>
<th data-col="product">Product</th>
<th data-col="status">Status</th>
<th data-col="created">Created</th>
<th data-col="updated">Last update</th>
<th data-col="total">Total</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td
colSpan={7}
className="orders-empty"
data-testid="bss-orders-table-loading"
>
Loading orders
</td>
</tr>
) : visibleOrders.length === 0 ? (
<tr>
<td
colSpan={7}
className="orders-empty"
data-testid="bss-orders-table-empty"
>
{orders.length === 0
? 'No orders yet. Tenant orders from the marketplace will appear here.'
: 'No orders match the current filters.'}
</td>
</tr>
) : (
visibleOrders.map((o) => <OrderRow key={o.id} order={o} />)
)}
</tbody>
</table>
</div>
</div>
</PortalShell>
)
}
/* ── Row ─────────────────────────────────────────────────────────── */
function OrderRow({ order }: { order: Order }) {
const created = formatRelative(order.createdAt)
const updated = formatRelative(order.updatedAt)
return (
<tr
className="orders-row"
data-testid={`bss-orders-row-${order.id}`}
data-status={order.status}
>
<td className="orders-cell orders-cell-id">
<Link
to={`/bss/orders/${order.id}` as never}
className="orders-row-link"
data-testid={`bss-orders-row-link-${order.id}`}
>
{order.id}
</Link>
</td>
<td className="orders-cell orders-cell-tenant">
{order.tenantOrg ? (
order.tenantOrg
) : (
<span className="orders-empty-cell"></span>
)}
</td>
<td className="orders-cell orders-cell-product">
{order.product ? (
order.product
) : (
<span className="orders-empty-cell"></span>
)}
</td>
<td className="orders-cell orders-cell-status">
<StatusPill status={order.status} orderId={order.id} />
</td>
<td className="orders-cell orders-cell-created" title={created.absolute}>
<span data-testid={`bss-orders-cell-created-${order.id}`}>
{created.display}
</span>
</td>
<td className="orders-cell orders-cell-updated" title={updated.absolute}>
<span data-testid={`bss-orders-cell-updated-${order.id}`}>
{updated.display}
</span>
</td>
<td className="orders-cell orders-cell-total">
<span
className="tabular-nums"
data-testid={`bss-orders-cell-total-${order.id}`}
>
{formatCurrency(order.totalCents, order.currency)}
</span>
</td>
</tr>
)
}
function StatusPill({ status, orderId }: { status: OrderStatus; orderId: string }) {
return (
<span
className={`orders-pill orders-pill-${status}`}
data-testid={`bss-orders-cell-status-${orderId}`}
data-status={status}
>
{STATUS_LABEL[status]}
</span>
)
}
const STATUS_LABEL: Record<OrderStatus, string> = {
pending: 'Pending',
completed: 'Completed',
failed: 'Failed',
cancelled: 'Cancelled',
}
/* ── Helpers ─────────────────────────────────────────────────────── */
const DAY_MS = 24 * 60 * 60 * 1000
/**
* STATUS_PRIORITY sort pending first (operator wants in-flight work
* surfaced), then failed (action needed), then completed, then
* cancelled. Mirrors JobsTable's "running > pending > succeeded >
* failed" ordering philosophy (most-actionable on top).
*/
const STATUS_PRIORITY: Record<OrderStatus, number> = {
pending: 0,
failed: 1,
completed: 2,
cancelled: 3,
}
function compareOrders(a: Order, b: Order): number {
const pa = STATUS_PRIORITY[a.status] ?? 99
const pb = STATUS_PRIORITY[b.status] ?? 99
if (pa !== pb) return pa - pb
const ta = a.createdAt ? new Date(a.createdAt).getTime() : 0
const tb = b.createdAt ? new Date(b.createdAt).getTime() : 0
if (ta !== tb) return tb - ta
return a.id.localeCompare(b.id)
}
function matchOrder(o: Order, query: string): boolean {
const q = query.toLowerCase()
if (o.id.toLowerCase().includes(q)) return true
if (o.tenantOrg.toLowerCase().includes(q)) return true
if (o.product.toLowerCase().includes(q)) return true
if (o.status.toLowerCase().includes(q)) return true
return false
}
function formatRelative(iso: string): { display: string; absolute: string } {
if (!iso) return { display: '—', absolute: '' }
const t = new Date(iso).getTime()
if (!Number.isFinite(t) || t <= 0) return { display: '—', absolute: '' }
const now = Date.now()
const dSec = Math.floor((now - t) / 1000)
const display =
dSec < 5
? 'just now'
: dSec < 60
? `${dSec}s ago`
: dSec < 3600
? `${Math.floor(dSec / 60)}m ago`
: dSec < 86_400
? `${Math.floor(dSec / 3600)}h ago`
: new Date(t).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
})
const absolute = new Date(t).toLocaleString()
return { display, absolute }
}
function formatCurrency(cents: number, currency: string): string {
const value = cents / 100
try {
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency,
maximumFractionDigits: 2,
}).format(value)
} catch {
// Unknown ISO-4217 code → fall back to a plain numeric format with
// the raw code prefix so the cell still renders something useful.
return `${currency} ${value.toFixed(2)}`
}
}
/* Styles *
*
* Mirrors JobsTable's `.jobs-table-*` CSS verbatim, just prefixed
* `orders-*` so the two coexist without cascade collisions. All
* colour values flow through design tokens; the status pills use the
* same token-driven semantics SettingsPage and JobsTable already
* consume (success/accent/danger families via color-mix), with
* amber-* / rose-* as the brief's explicit exceptions.
*/
const ORDERS_TABLE_CSS = `
.orders-toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: flex-end;
margin-bottom: 0.75rem;
}
.orders-search-wrap {
position: relative;
flex: 1 1 280px;
min-width: 240px;
max-width: 480px;
}
.orders-search-icon {
position: absolute;
left: 0.6rem;
top: 50%;
transform: translateY(-50%);
width: 14px;
height: 14px;
color: var(--color-text-dim);
}
.orders-search-input {
width: 100%;
padding: 0.32rem 0.7rem 0.32rem 1.9rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-size: 0.82rem;
outline: none;
transition: border-color 0.15s ease;
}
.orders-search-input:focus {
border-color: var(--color-accent);
}
.orders-filters {
display: flex;
gap: 0.6rem;
align-items: flex-end;
flex-wrap: wrap;
}
.orders-filter-label {
display: inline-flex;
flex-direction: column;
gap: 0.15rem;
}
.orders-filter-caption {
font-size: 0.62rem;
color: var(--color-text-dim);
text-transform: uppercase;
letter-spacing: 0.06em;
}
.orders-filter-select {
padding: 0.32rem 0.5rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 6px;
color: var(--color-text);
font-size: 0.82rem;
cursor: pointer;
}
.orders-result-count {
font-size: 0.72rem;
color: var(--color-text-dim);
align-self: flex-end;
margin-left: auto;
padding-bottom: 0.32rem;
font-variant-numeric: tabular-nums;
}
.orders-table-scroll {
width: 100%;
overflow-x: auto;
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface);
}
.orders-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
}
.orders-table thead th {
padding: 0.55rem 0.8rem;
text-align: left;
background: color-mix(in srgb, var(--color-border) 35%, transparent);
border-bottom: 1px solid var(--color-border);
color: var(--color-text-dim);
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
font-weight: 600;
white-space: nowrap;
}
.orders-row {
border-bottom: 1px solid var(--color-border);
transition: background-color 0.12s ease;
}
.orders-row:last-of-type {
border-bottom: none;
}
.orders-row:hover {
background: color-mix(in srgb, var(--color-accent) 5%, transparent);
}
.orders-cell {
padding: 0.55rem 0.8rem;
vertical-align: middle;
color: var(--color-text);
}
.orders-cell-id { min-width: 200px; font-family: var(--font-mono, ui-monospace, monospace); }
.orders-cell-tenant { min-width: 160px; }
.orders-cell-product { min-width: 160px; }
.orders-cell-total { text-align: right; min-width: 100px; }
.orders-row-link {
display: inline-block;
text-decoration: none;
color: var(--color-text-strong);
font-weight: 500;
cursor: pointer;
}
.orders-row-link:hover {
color: var(--color-accent);
}
.orders-empty-cell {
color: var(--color-text-dim);
font-size: 0.78rem;
}
.orders-empty {
padding: 2rem 1rem;
text-align: center;
color: var(--color-text-dim);
font-size: 0.85rem;
}
.orders-pill {
display: inline-flex;
align-items: center;
padding: 0.12rem 0.55rem;
border-radius: 999px;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
white-space: nowrap;
border: 1px solid transparent;
}
.orders-pill-pending {
background: color-mix(in srgb, var(--color-accent) 12%, transparent);
color: var(--color-accent);
border-color: color-mix(in srgb, var(--color-accent) 35%, transparent);
}
.orders-pill-completed {
background: color-mix(in srgb, var(--color-success) 14%, transparent);
color: var(--color-success);
border-color: color-mix(in srgb, var(--color-success) 35%, transparent);
}
.orders-pill-failed {
background: color-mix(in srgb, var(--color-error) 14%, transparent);
color: var(--color-error);
border-color: color-mix(in srgb, var(--color-error) 35%, transparent);
}
.orders-pill-cancelled {
background: color-mix(in srgb, var(--color-text-dim) 14%, transparent);
color: var(--color-text-dim);
border-color: color-mix(in srgb, var(--color-text-dim) 30%, transparent);
}
`