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:
parent
393116355d
commit
239eb4fffd
@ -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'
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
`
|
||||
|
||||
Loading…
Reference in New Issue
Block a user