From c7c90cfc1e6dc508937d87c561fb0e608ae88956 Mon Sep 17 00:00:00 2001
From: DanMat
Date: Sun, 30 Aug 2026 14:22:12 -0400
Subject: [PATCH] =?UTF-8?q?feat:=20Workbench=20Phase=201=20=E2=80=94=20gat?=
=?UTF-8?q?e=20the=20admin=20page,=20order=20lifecycle=20buttons,=20curren?=
=?UTF-8?q?cy,=20theme-safe=20pills,=20honest=20errors?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Now that core can gate a plugin admin page on the plugin's own capability
(ADR 0020), the Commerce page and all its actions require
`nimbuscms.commerce:write` — parity with the MCP tools, so a content-only editor
can no longer place or advance orders from the UI.
- Per-row lifecycle buttons: pay / fulfil / cancel (CSRF-protected POSTs to H3
actions), shown by status. The UI catching up to the MCP tools.
- Status pills now use the admin theme's semantic tokens (var(--nb-*-bg/-text))
instead of hard-coded hex, so they stay legible in dark and every theme.
- Totals render in the order's currency (a small symbol map from the
commerce_order.currency column, which the query now selects) instead of a
literal "$".
- SKU datalist on the place form, sourced from this plugin's own order lines
(never inventory_* — the plugin boundary).
- Allow-listed ?status= filter on the orders table (bound; junk is ignored, not
reflected).
- Honest errors via typed exceptions: OrderNotFound -> notfound, IllegalTransition
-> badstate, NoInventory -> noinventory, bad qty/price -> badqty (validated at
the boundary, so a DECIMAL DB error can't masquerade as something else),
InsufficientStock -> short.
OrderBook now throws the typed exceptions (all extend \RuntimeException, so
existing catchers keep working).
Tests: CommerceAdminTest (render — theme-token pills, currency, lifecycle
buttons, allow-listed filter, datalist) and CommerceAdminActionsTest (the H3
actions through the real loader with Inventory alongside — the full lifecycle and
every honest-error path). 22 tests green; PHPStan + php-cs-fixer clean.
Co-Authored-By: Claude Opus 4.8
---
src/CommerceAdmin.php | 168 +++++++++++++++++++++++------
src/CommercePlugin.php | 50 ++++++++-
src/IllegalTransition.php | 19 ++++
src/NoInventory.php | 16 +++
src/OrderBook.php | 10 +-
src/OrderNotFound.php | 19 ++++
tests/CommerceAdminActionsTest.php | 145 +++++++++++++++++++++++++
tests/CommerceAdminTest.php | 117 ++++++++++++++++++++
8 files changed, 503 insertions(+), 41 deletions(-)
create mode 100644 src/IllegalTransition.php
create mode 100644 src/NoInventory.php
create mode 100644 src/OrderNotFound.php
create mode 100644 tests/CommerceAdminActionsTest.php
create mode 100644 tests/CommerceAdminTest.php
diff --git a/src/CommerceAdmin.php b/src/CommerceAdmin.php
index 49e3c9c..69b3364 100644
--- a/src/CommerceAdmin.php
+++ b/src/CommerceAdmin.php
@@ -7,29 +7,42 @@
use Nimbus\Plugin\PluginStorage;
/**
- * The Commerce admin page — a read-only list of orders with their status, total
- * and lines. Registered as a GET-only plugin admin page; orders are placed and
- * moved through the MCP tools (or an agent), so this is a window, not an editor.
+ * The Commerce admin page — the orders list with their status, total and lines,
+ * a quick place-order form, and per-row lifecycle buttons (pay, fulfil, cancel).
+ * The same lifecycle an agent drives over MCP; this is the human hand on it.
*
* Customer emails and SKU codes can originate from callers, so every value is
- * escaped before it reaches the page.
+ * escaped before it reaches the page. Status "pills" use the admin theme's
+ * semantic tokens (which redefine per theme) rather than hard-coded colours, so
+ * they stay legible in dark and every selectable theme.
*/
final class CommerceAdmin
{
+ /** status => [background token, text token] — all theme-defined, so dark-safe. */
private const STATUS_TONE = [
- 'pending' => '#9a6a12',
- 'paid' => '#0f766e',
- 'fulfilled' => '#5751d6',
- 'cancelled' => '#a5386b',
+ 'pending' => ['--nb-warn-bg', '--nb-warn-text'],
+ 'paid' => ['--nb-brand-tint', '--nb-link-color'],
+ 'fulfilled' => ['--nb-ok-bg', '--nb-ok-text'],
+ 'cancelled' => ['--nb-surface-2', '--nb-muted'],
+ ];
+
+ /** A tiny symbol map — no ext-intl dependency; unknown codes render as "12.50 XYZ". */
+ private const SYMBOL = [
+ 'USD' => '$', 'EUR' => '€', 'GBP' => '£', 'JPY' => '¥',
+ 'AUD' => 'A$', 'CAD' => 'C$', 'NZD' => 'NZ$', 'INR' => '₹',
];
private const NOTICES = [
- 'placed' => ['ok', 'Order placed and stock reserved.'],
- 'paid' => ['ok', 'Order marked paid.'],
- 'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'],
- 'cancelled' => ['ok', 'Order cancelled — stock released.'],
- 'short' => ['err', 'Not enough stock available to place that order.'],
- 'invalid' => ['err', 'Check the SKU and quantity and try again.'],
+ 'placed' => ['ok', 'Order placed and stock reserved.'],
+ 'paid' => ['ok', 'Order marked paid.'],
+ 'fulfilled' => ['ok', 'Order fulfilled — stock shipped.'],
+ 'cancelled' => ['ok', 'Order cancelled — stock released.'],
+ 'short' => ['err', 'Not enough stock available to place that order.'],
+ 'badqty' => ['err', 'Enter a valid quantity and unit price.'],
+ 'noinventory' => ['err', 'Install the Inventory plugin — an order reserves stock against it.'],
+ 'notfound' => ['err', 'No order with that reference.'],
+ 'badstate' => ['err', 'That order can’t move to that state.'],
+ 'invalid' => ['err', 'Check the SKU and quantity and try again.'],
];
/** @param \Closure():PluginStorage $storage */
@@ -40,11 +53,23 @@ public function __construct(private \Closure $storage)
/**
* @param string $csrf CSRF token for the forms (passed by core to the page handler)
* @param ?string $notice a fixed notice code (from the ?ok=/?err= redirect)
+ * @param ?string $status a status filter (from ?status=), allow-listed to the known statuses
*/
- public function render(string $csrf = '', ?string $notice = null): string
+ public function render(string $csrf = '', ?string $notice = null, ?string $status = null): string
{
- $s = ($this->storage)();
- $orders = $s->select('SELECT id, reference, status, customer_email, total, placed_at FROM ' . Schema::ORDER . ' ORDER BY id DESC LIMIT 50');
+ $s = ($this->storage)();
+
+ // Allow-list the filter: an unknown value is ignored (never reaches SQL).
+ $status = ($status !== null && isset(self::STATUS_TONE[$status])) ? $status : null;
+ $where = $status === null ? '' : ' WHERE status = :status';
+ $params = $status === null ? [] : ['status' => $status];
+ $orders = $s->select('SELECT id, reference, status, customer_email, currency, total, placed_at FROM ' . Schema::ORDER . $where . ' ORDER BY id DESC LIMIT 50', $params);
+
+ /** @var list $skus SKUs sold before — the place-form suggestions (own table; boundary-safe) */
+ $skus = array_map(
+ static fn (array $r): string => (string) $r['sku_code'],
+ $s->select('SELECT DISTINCT sku_code FROM ' . Schema::LINE . ' ORDER BY sku_code'),
+ );
$banner = '';
if ($notice !== null && isset(self::NOTICES[$notice])) {
@@ -59,49 +84,130 @@ public function render(string $csrf = '', ?string $notice = null): string
$html = '
Commerce
' . $banner
. '
Orders reserve stock against Inventory. '
- . 'Place a quick order below, or drive the full lifecycle (pay, fulfil, cancel) over MCP.
'
+ . 'Place an order below and advance it with the buttons, or drive the lifecycle over MCP.