Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
134 changes: 104 additions & 30 deletions src/InventoryAdmin.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,27 @@
use Nimbus\Plugin\PluginStorage;

/**
* The Inventory admin page — a read-only overview of stock (on-hand, reserved,
* available per SKU and location) and the most recent movements. Registered as a
* GET-only plugin admin page; changes are made through the MCP tools (or an agent),
* so this is a window, not an editor.
* The Inventory admin page — an overview of stock (on-hand, reserved, available
* per SKU and location) and the most recent movements, plus the four ledger
* actions as forms (receive, adjust, count, transfer). The same operations an
* agent drives over MCP; this is the human hand on the same ledger.
*
* SKU codes and locations can originate from tool callers, so every value is
* escaped before it reaches the page.
* SKU codes, locations and the filter term can originate from callers, so every
* value is escaped before it reaches the page. The SKU/location inputs offer a
* `<datalist>` of what already exists (a typo-guard, not a hard gate — receiving a
* genuinely new SKU is still allowed by typing it).
*/
final class InventoryAdmin
{
private const NOTICES = [
'received' => ['ok', 'Stock received.'],
'adjusted' => ['ok', 'Stock adjusted.'],
'invalid' => ['err', 'Check the SKU and quantity and try again.'],
'short' => ['err', 'Not enough available for that adjustment.'],
'received' => ['ok', 'Stock received.'],
'adjusted' => ['ok', 'Stock adjusted.'],
'counted' => ['ok', 'Stock count recorded.'],
'transferred' => ['ok', 'Stock transferred.'],
'short' => ['err', 'Not enough available for that movement.'],
'badqty' => ['err', 'Enter a valid quantity — a number with up to 4 decimal places (adjustments may be negative).'],
'samelocation' => ['err', 'Choose two different locations to transfer between.'],
'invalid' => ['err', 'Check the SKU and quantity and try again.'],
];

/** @param \Closure():PluginStorage $storage */
Expand All @@ -32,10 +38,12 @@ public function __construct(private \Closure $storage)
/**
* @param string $csrf the CSRF token for the forms (passed by core to the page handler)
* @param ?string $notice a fixed notice code (from the ?ok=/?err= redirect), mapped to a message
* @param ?string $q a SKU filter substring (from ?q=), applied to the stock table
*/
public function render(string $csrf = '', ?string $notice = null): string
public function render(string $csrf = '', ?string $notice = null, ?string $q = null): string
{
$s = ($this->storage)();
$q = $q !== null ? trim($q) : '';

$banner = '';
if ($notice !== null && isset(self::NOTICES[$notice])) {
Expand All @@ -44,16 +52,26 @@ public function render(string $csrf = '', ?string $notice = null): string
}

$locations = [];
foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION) as $l) {
foreach ($s->select('SELECT id, code FROM ' . Schema::LOCATION . ' ORDER BY code') as $l) {
$locations[(int) $l['id']] = (string) $l['code'];
}
/** @var list<string> $skus distinct SKUs already stocked — the datalist suggestions */
$skus = array_map(
static fn (array $r): string => (string) $r['sku_code'],
$s->select('SELECT DISTINCT sku_code FROM ' . Schema::STOCK . ' ORDER BY sku_code'),
);

$stock = $s->select(
// Stock, optionally filtered to SKUs containing the term (bound LIKE — no
// string-built SQL). available = on_hand − reserved.
$where = $q === '' ? '' : ' WHERE s.sku_code LIKE :q';
$params = $q === '' ? [] : ['q' => '%' . $q . '%'];
$stock = $s->select(
'SELECT s.sku_code, s.location_id, s.on_hand, s.uom,
COALESCE((SELECT SUM(qty) FROM ' . Schema::RESERVATION . ' r
WHERE r.sku_code = s.sku_code AND r.location_id = s.location_id), 0) AS reserved
FROM ' . Schema::STOCK . ' s
FROM ' . Schema::STOCK . ' s' . $where . '
ORDER BY s.sku_code, s.location_id',
$params,
);

$movements = $s->select(
Expand All @@ -63,11 +81,15 @@ public function render(string $csrf = '', ?string $notice = null): string

$html = '<div class="nb-page-head"><h1>Inventory</h1></div>' . $banner
. '<p class="nb-muted" style="margin:-8px 0 20px">Stock as an append-only ledger — on-hand, reserved and available per location. '
. 'Receive or adjust below, or drive it over MCP (an agent can also count and transfer).</p>'
. 'Receive, adjust, count or transfer below, or drive it over MCP.</p>'
. $this->datalists($skus, array_values($locations))
. $this->forms($csrf);

$html .= $this->filterForm($q);
if ($stock === []) {
$html .= '<p class="nb-muted">No stock yet. Receive some with the <code>inventory_receive</code> tool.</p>';
$html .= $q === ''
? '<p class="nb-muted">No stock yet. Receive some above, or with the <code>inventory_receive</code> tool.</p>'
: '<p class="nb-muted">No stock matches “' . $this->e($q) . '”.</p>';
} else {
$html .= '<div class="nb-table-wrap nb-stack"><table class="nb-table"><thead><tr>'
. '<th>SKU</th><th>Location</th><th style="text-align:right">On hand</th>'
Expand Down Expand Up @@ -107,36 +129,88 @@ public function render(string $csrf = '', ?string $notice = null): string
return $html;
}

/** The receive + adjust forms. Each posts to its plugin admin action with the CSRF token. */
/**
* The known-SKU and known-location suggestion lists, referenced by the form
* inputs. Suggestions only — a new SKU/location can still be typed.
*
* @param list<string> $skus
* @param list<string> $locations
*/
private function datalists(array $skus, array $locations): string
{
$opts = static function (array $values, callable $e): string {
$out = '';
foreach ($values as $v) {
$out .= '<option value="' . $e($v) . '"></option>';
}
return $out;
};

return '<datalist id="inv-skus">' . $opts($skus, [$this, 'e']) . '</datalist>'
. '<datalist id="inv-locs">' . $opts($locations, [$this, 'e']) . '</datalist>';
}

/** The four ledger forms (receive, adjust, count, transfer). Each posts to its action with the CSRF token. */
private function forms(string $csrf): string
{
$t = '<input type="hidden" name="_token" value="' . $this->e($csrf) . '">';

return '<div style="display:flex;gap:1rem;flex-wrap:wrap;margin-bottom:1.5rem">'
. '<form class="nb-form-card" method="post" action="/admin/inventory/receive" style="flex:1 1 260px">'
. '<form class="nb-form-card" method="post" action="/admin/inventory/receive" style="flex:1 1 240px">'
. '<h2>Receive stock</h2>' . $t
. $this->field('SKU', 'sku', 'text', 'e.g. house-blend')
. $this->field('Location', 'location', 'text', 'main')
. $this->field('Quantity', 'qty', 'text', 'e.g. 12')
. $this->field('Unit', 'uom', 'text', 'each')
. $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus')
. $this->field('Location', 'location', 'main', 'inv-locs')
. $this->field('Quantity', 'qty', 'e.g. 12')
. $this->field('Unit', 'uom', 'each')
. '<button type="submit" class="nb-btn nb-btn-primary">Receive</button></form>'
. '<form class="nb-form-card" method="post" action="/admin/inventory/adjust" style="flex:1 1 260px">'

. '<form class="nb-form-card" method="post" action="/admin/inventory/adjust" style="flex:1 1 240px">'
. '<h2>Adjust stock</h2>' . $t
. $this->field('SKU', 'sku', 'text', 'e.g. house-blend')
. $this->field('Location', 'location', 'text', 'main')
. $this->field('Change (+/−)', 'qty', 'text', 'e.g. -3')
. $this->field('Reason', 'reason', 'text', 'waste')
. $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus')
. $this->field('Location', 'location', 'main', 'inv-locs')
. $this->field('Change (+/−)', 'qty', 'e.g. -3')
. $this->field('Reason', 'reason', 'waste')
. '<button type="submit" class="nb-btn nb-btn-primary">Adjust</button></form>'

. '<form class="nb-form-card" method="post" action="/admin/inventory/count" style="flex:1 1 240px">'
. '<h2>Count stock</h2>' . $t
. '<p class="nb-muted" style="margin-top:-6px;font-size:.85rem">Set on-hand to a counted figure; the correction is recorded as a movement.</p>'
. $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus')
. $this->field('Location', 'location', 'main', 'inv-locs')
. $this->field('Counted', 'qty', 'e.g. 40')
. '<button type="submit" class="nb-btn nb-btn-primary">Record count</button></form>'

. '<form class="nb-form-card" method="post" action="/admin/inventory/transfer" style="flex:1 1 240px">'
. '<h2>Transfer stock</h2>' . $t
. $this->field('SKU', 'sku', 'e.g. house-blend', 'inv-skus')
. $this->field('From', 'from', 'main', 'inv-locs')
. $this->field('To', 'to', 'store', 'inv-locs')
. $this->field('Quantity', 'qty', 'e.g. 6')
. '<button type="submit" class="nb-btn nb-btn-primary">Transfer</button></form>'
. '</div>';
}

private function field(string $label, string $name, string $type, string $placeholder): string
/** A SKU substring filter for the stock table (GET, no JS). */
private function filterForm(string $q): string
{
return '<form method="get" action="/admin/inventory" class="nb-stack" style="display:flex;gap:.5rem;flex-wrap:wrap;align-items:flex-end;margin-bottom:.75rem">'
. '<div class="nb-field" style="flex:1 1 220px;margin:0"><label for="inv-q">Filter by SKU</label>'
. '<input id="inv-q" type="search" name="q" value="' . $this->e($q) . '" placeholder="e.g. house"></div>'
. '<button type="submit" class="nb-btn">Filter</button>'
. ($q === '' ? '' : ' <a class="nb-btn" href="/admin/inventory">Clear</a>')
. '</form>';
}

private function field(string $label, string $name, string $placeholder, ?string $list = null): string
{
$listAttr = $list === null ? '' : ' list="' . $this->e($list) . '"';

return '<div class="nb-field"><label for="inv-' . $this->e($name) . '">' . $this->e($label) . '</label>'
. '<input type="' . $this->e($type) . '" id="inv-' . $this->e($name) . '" name="' . $this->e($name) . '" placeholder="' . $this->e($placeholder) . '"></div>';
. '<input type="text" id="inv-' . $this->e($name) . '" name="' . $this->e($name) . '"' . $listAttr
. ' placeholder="' . $this->e($placeholder) . '"></div>';
}

private function e(string $v): string
public function e(string $v): string
{
return htmlspecialchars($v, ENT_QUOTES, 'UTF-8');
}
Expand Down
57 changes: 54 additions & 3 deletions src/InventoryPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,17 @@ public function register(PluginContext $context): void
// stock synchronously without touching Inventory's tables (ADR 0019).
$context->services()->provide(ReservationPort::class, new ReservationAdapter($ledger, $reservations));

// Admin page: an overview plus receive/adjust forms (H3). The page handler
// gets a CSRF token (3rd arg) for the forms and shows the redirect notice.
// Admin page: an overview plus the receive/adjust/count/transfer forms (H3).
// Gated on this plugin's own wildcard-immune capability (ADR 0020) — moving
// stock in the UI needs `nimbuscms.inventory:write`, exactly like the MCP
// tools, so a content-only editor can't. The handler gets a CSRF token (3rd
// arg) for the forms and shows the redirect notice.
$context->adminPages()->register(
'inventory',
'Inventory',
'📦',
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err')),
static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('q')),
self::ID . ':write',
);
$context->adminPages()->action('inventory', 'receive', static function (Request $r) use ($ledger): Response {
$sku = trim((string) ($r->input('sku') ?? ''));
Expand All @@ -72,6 +76,8 @@ public function register(PluginContext $context): void
$now = date('Y-m-d H:i:s');
$ledger->receive($sku, $ledger->ensureLocation($loc, $loc, $now), $qty, $uom, 'admin-ui', $now);
return Response::redirect('/admin/inventory?ok=received');
} catch (\InvalidArgumentException) {
return Response::redirect('/admin/inventory?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/inventory?err=invalid');
}
Expand All @@ -91,6 +97,51 @@ public function register(PluginContext $context): void
return Response::redirect('/admin/inventory?ok=adjusted');
} catch (InsufficientStock) {
return Response::redirect('/admin/inventory?err=short');
} catch (\InvalidArgumentException) {
return Response::redirect('/admin/inventory?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/inventory?err=invalid');
}
});
$context->adminPages()->action('inventory', 'count', static function (Request $r) use ($ledger): Response {
$sku = trim((string) ($r->input('sku') ?? ''));
$counted = trim((string) ($r->input('qty') ?? ''));
if ($sku === '' || $counted === '') {
return Response::redirect('/admin/inventory?err=invalid');
}
$loc = trim((string) ($r->input('location') ?? '')) ?: 'main';
try {
$now = date('Y-m-d H:i:s');
$locId = $ledger->ensureLocation($loc, $loc, $now);
$ledger->count($sku, $locId, $counted, $ledger->uomFor($sku, $locId) ?? 'each', 'admin-ui', $now);
return Response::redirect('/admin/inventory?ok=counted');
} catch (\InvalidArgumentException) {
return Response::redirect('/admin/inventory?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/inventory?err=invalid');
}
});
$context->adminPages()->action('inventory', 'transfer', static function (Request $r) use ($ledger): Response {
$sku = trim((string) ($r->input('sku') ?? ''));
$qty = trim((string) ($r->input('qty') ?? ''));
$from = trim((string) ($r->input('from') ?? ''));
$to = trim((string) ($r->input('to') ?? ''));
if ($sku === '' || $qty === '' || $from === '' || $to === '') {
return Response::redirect('/admin/inventory?err=invalid');
}
if ($from === $to) {
return Response::redirect('/admin/inventory?err=samelocation');
}
try {
$now = date('Y-m-d H:i:s');
$fromId = $ledger->ensureLocation($from, $from, $now);
$toId = $ledger->ensureLocation($to, $to, $now);
$ledger->transfer($sku, $fromId, $toId, $qty, $ledger->uomFor($sku, $fromId) ?? 'each', 'admin-ui', $now);
return Response::redirect('/admin/inventory?ok=transferred');
} catch (InsufficientStock) {
return Response::redirect('/admin/inventory?err=short');
} catch (\InvalidArgumentException) {
return Response::redirect('/admin/inventory?err=badqty');
} catch (\Throwable) {
return Response::redirect('/admin/inventory?err=invalid');
}
Expand Down
Loading
Loading