diff --git a/src/InventoryAdmin.php b/src/InventoryAdmin.php index f021704..70e6e0f 100644 --- a/src/InventoryAdmin.php +++ b/src/InventoryAdmin.php @@ -39,17 +39,17 @@ 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 + * @param ?string $sku a specific SKU (from ?sku=) — renders the drill-down instead of the overview */ - public function render(string $csrf = '', ?string $notice = null, ?string $q = null): string + public function render(string $csrf = '', ?string $notice = null, ?string $q = null, ?string $sku = null): string { $s = ($this->storage)(); + if ($sku !== null && trim($sku) !== '') { + return $this->renderDetail($s, trim($sku), $notice); + } $q = $q !== null ? trim($q) : ''; - $banner = ''; - if ($notice !== null && isset(self::NOTICES[$notice])) { - [$kind, $msg] = self::NOTICES[$notice]; - $banner = '
' . $this->e((string) $r['sku_code']) . '' . $this->e((string) $m['sku_code']) . '' . $this->e($sku) . 'Nothing recorded for this SKU. It may be a mistyped code — check the stock list.
'; + } + + // Stock by location. + $html .= 'None on hand.
'; + } else { + $html .= '| Location | On hand | ' + . 'Reserved | Available |
|---|---|---|---|
| ' . $this->e($loc((int) $r['location_id'])) . ' | ' + . '' . $this->e($onHand) . ' ' . $this->e((string) $r['uom']) . ' | ' + . '' . $this->e((string) $r['reserved']) . ' | ' + . '' . $this->e($avail) . ' |
No stock is currently reserved.
'; + } else { + $html .= '| Ref | Location | Qty | Age |
|---|---|---|---|
' . $this->e((string) $h['ref']) . ' | '
+ . '' . $this->e($loc((int) $h['location_id'])) . ' | ' + . '' . $this->e((string) $h['qty']) . ' | ' + . '' . $this->e($this->age((string) $h['created_at'])) . ' |
No movements.
'; + } else { + $html .= '| Qty | Reason | Ref | Actor | When |
|---|---|---|---|---|
| ' . $this->e((string) $m['qty']) . ' ' . $this->e((string) $m['uom']) . ' | ' + . '' . $this->e((string) $m['reason']) . ' | ' + . '' . ($ref === '' ? '—' : $this->e($ref)) . ' | ' + . '' . $this->e((string) $m['actor']) . ' | ' + . '' . $this->e((string) $m['occurred_at']) . ' |
' . $this->e($sku) . '';
+ }
+
+ /** A compact human age from a 'Y-m-d H:i:s' timestamp (e.g. "3d 4h", "just now"). */
+ private function age(string $ts): string
+ {
+ $then = strtotime($ts);
+ if ($then === false) {
+ return $ts;
+ }
+ $secs = max(0, time() - $then);
+ if ($secs < 60) {
+ return 'just now';
+ }
+ $mins = intdiv($secs, 60);
+ if ($mins < 60) {
+ return $mins . 'm';
+ }
+ $hours = intdiv($mins, 60);
+ if ($hours < 24) {
+ return $hours . 'h ' . ($mins % 60) . 'm';
+ }
+ $days = intdiv($hours, 24);
+ return $days . 'd ' . ($hours % 24) . 'h';
+ }
+
/**
* The known-SKU and known-location suggestion lists, referenced by the form
* inputs. Suggestions only — a new SKU/location can still be typed.
diff --git a/src/InventoryPlugin.php b/src/InventoryPlugin.php
index f1157ab..06d7cc1 100644
--- a/src/InventoryPlugin.php
+++ b/src/InventoryPlugin.php
@@ -61,7 +61,7 @@ public function register(PluginContext $context): void
'inventory',
'Inventory',
'📦',
- static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('q')),
+ static fn (Request $r, string $nonce = '', string $csrf = ''): string => (new InventoryAdmin($storage))->render($csrf, $r->query('ok') ?? $r->query('err'), $r->query('q'), $r->query('sku')),
self::ID . ':write',
);
$context->adminPages()->action('inventory', 'receive', static function (Request $r) use ($ledger): Response {
diff --git a/tests/InventoryAdminTest.php b/tests/InventoryAdminTest.php
index f217a6a..d45f907 100644
--- a/tests/InventoryAdminTest.php
+++ b/tests/InventoryAdminTest.php
@@ -8,6 +8,7 @@
use Nimbus\Plugin\PluginStorage;
use NimbusCMS\Inventory\InventoryAdmin;
use NimbusCMS\Inventory\Ledger;
+use NimbusCMS\Inventory\Reservations;
use NimbusCMS\Inventory\Schema;
use PHPUnit\Framework\TestCase;
@@ -94,4 +95,44 @@ public function test_a_notice_code_maps_to_a_message_and_unknown_shows_nothing()
self::assertStringContainsString('Choose two different locations', $this->admin->render('tok', 'samelocation'));
self::assertStringNotContainsString('nb-notice', $this->admin->render('tok', 'nonsense-code'));
}
+
+ public function test_the_stock_rows_link_to_the_sku_drilldown(): void
+ {
+ $html = $this->admin->render('tok');
+ self::assertStringContainsString('href="/admin/inventory?sku=house-blend"', $html);
+ }
+
+ public function test_the_sku_drilldown_shows_stock_holds_and_trail(): void
+ {
+ // Put a hold on house-blend so the drill-down has an open hold to explain.
+ $storage = new PluginStorage($this->db);
+ $ledger = new Ledger(static fn (): PluginStorage => $storage);
+ $res = new Reservations(static fn (): PluginStorage => $storage, $ledger);
+ $loc = $ledger->ensureLocation('main', 'Main', '2026-01-01 09:00:00');
+ $res->reserve('house-blend', $loc, '5', 'ORD-TEST:1', '2026-01-01 10:00:00');
+
+ $html = $this->admin->render('tok', null, null, 'house-blend');
+
+ self::assertStringContainsString('SKU house-blend', $html);
+ self::assertStringContainsString('Stock by location', $html);
+ self::assertStringContainsString('Open holds', $html);
+ self::assertStringContainsString('ORD-TEST:1', $html, 'the hold ref explains the reservation');
+ self::assertStringContainsString('Movement trail', $html);
+ self::assertStringContainsString('receipt', $html, 'the seeding receipt is in the trail');
+ }
+
+ public function test_the_drilldown_escapes_and_binds_a_hostile_sku(): void
+ {
+ // Reflected-XSS + SQLi guard on ?sku=: bound (no error, no rows) and escaped.
+ $html = $this->admin->render('tok', null, null, '">');
+
+ self::assertStringNotContainsString('', $html);
+ self::assertStringContainsString('Nothing recorded for this SKU', $html);
+ }
+
+ public function test_an_unknown_sku_shows_a_helpful_note(): void
+ {
+ $html = $this->admin->render('tok', null, null, 'no-such-sku');
+ self::assertStringContainsString('Nothing recorded for this SKU', $html);
+ }
}