From 03ef99d67b523292ce7716c140a165dd677bad83 Mon Sep 17 00:00:00 2001 From: Imugiii Date: Wed, 24 Jun 2026 12:33:12 +0000 Subject: [PATCH] feat(back-office): page Stock en tableau de bord (alertes + reappro en avant) Refonte de la page d'accueil Ingredients/Stock, jugee trop chargee et opaque. Desormais : un bandeau explique le lien stock -> disponibilite borne (un ingredient requis sous le seuil critique rend les produits qui l'utilisent indisponibles a la commande, RG-T21) ; un resume compte les ingredients critiques / en alerte / au-dessus du seuil ; une section "A reapprovisionner" met en avant les ingredients bas (critiques d'abord) avec barre de niveau + bouton Reapprovisionner direct ; la liste complete passe au second plan et le CRUD (creer / modifier / supprimer) est relegue. Les sous-pages (reappro, inventaire, mouvements, creation) restent inchangees. index() expose les compteurs par etat (testables). Tests : IngredientController +4 cas (bandeau, promotion d'un critique en section reappro, etat vide positif, compteurs par etat). PHP unit 409, JS 119, PHPStan L6. --- src/app/Controllers/IngredientController.php | 14 +- src/app/Views/admin/ingredients/index.php | 230 ++++++++++++------ src/public/admin/assets/css/admin.css | 226 +++++++++++++++++ tests/Unit/Admin/IngredientControllerTest.php | 58 +++++ 4 files changed, 453 insertions(+), 75 deletions(-) diff --git a/src/app/Controllers/IngredientController.php b/src/app/Controllers/IngredientController.php index d272e90..d4f581d 100644 --- a/src/app/Controllers/IngredientController.php +++ b/src/app/Controllers/IngredientController.php @@ -47,10 +47,22 @@ class IngredientController extends AdminController return $guard; } + $ingredients = $this->ingredientRepository()->all(); + + // Compteurs par bande pour le resume du tableau de bord (3 pastilles). + // Calcules cote serveur a partir de stock_band deja resolu par le depot, + // pour que la vue reste declarative et la valeur testable directement. + $counts = ['critical' => 0, 'low' => 0, 'normal' => 0]; + foreach ($ingredients as $row) { + $band = (string) ($row['stock_band'] ?? 'normal'); + $counts[$band] = ($counts[$band] ?? 0) + 1; + } + return $this->adminView('admin/ingredients/index', [ 'title' => 'Stock - Wakdo Admin', 'activeNav' => 'stock', - 'ingredients' => $this->ingredientRepository()->all(), + 'ingredients' => $ingredients, + 'bandCounts' => $counts, 'canManage' => $this->may($guard, 'ingredient.manage'), 'canRestock' => $this->may($guard, 'stock.manage'), 'canCount' => $this->may($guard, 'stock.count'), diff --git a/src/app/Views/admin/ingredients/index.php b/src/app/Views/admin/ingredients/index.php index 3204d7c..2b1e896 100644 --- a/src/app/Views/admin/ingredients/index.php +++ b/src/app/Views/admin/ingredients/index.php @@ -3,11 +3,15 @@ declare(strict_types=1); /** - * Liste du stock (READ_STOCK 9.3), injectee dans admin/layout.php. Affiche le - * pourcentage et la bande calcules (RG-2) ; les liens d'action sont conditionnes - * aux permissions (la garde reelle reste par-route). Texte echappe. + * Tableau de bord stock (READ_STOCK 9.3), injecte dans admin/layout.php. Oriente + * usage quotidien : on met en avant ce qui est bas a reapprovisionner, le CRUD de + * definition (config rare) est relegue. Le lien metier explique a quoi sert le stock : + * un ingredient requis sous le seuil critique rend les produits qui l'utilisent + * indisponibles sur la borne (RG-T21). Pourcentage/bande resolus cote depot ; les + * liens d'action restent conditionnes aux permissions (garde reelle par-route). Texte echappe. * * @var array> $ingredients + * @var array $bandCounts * @var bool $canManage * @var bool $canRestock * @var bool $canCount @@ -16,94 +20,172 @@ declare(strict_types=1); /** @var array> $rows */ $rows = isset($ingredients) && is_array($ingredients) ? $ingredients : []; +/** @var array $counts */ +$counts = isset($bandCounts) && is_array($bandCounts) ? $bandCounts : []; $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8'); $csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8'); $manage = (bool) ($canManage ?? false); $restock = (bool) ($canRestock ?? false); $count = (bool) ($canCount ?? false); -$bandLabel = static fn (string $band): string => match ($band) { - 'critical' => 'pill pill-danger', - 'low' => 'pill pill-warning', - default => 'pill pill-success', +$nCritical = (int) ($counts['critical'] ?? 0); +$nLow = (int) ($counts['low'] ?? 0); +$nNormal = (int) ($counts['normal'] ?? 0); + +// Les ingredients a reapprovisionner : critiques d'abord, puis en alerte. Le reste +// (au-dessus des seuils) va dans la liste calme "Tous les ingredients" plus bas. +$critical = []; +$low = []; +foreach ($rows as $row) { + $band = (string) ($row['stock_band'] ?? 'normal'); + if ($band === 'critical') { + $critical[] = $row; + } elseif ($band === 'low') { + $low[] = $row; + } +} +$toRestock = array_merge($critical, $low); + +$barClass = static fn (string $band): string => match ($band) { + 'critical' => 'stock-bar__fill stock-bar--critical', + 'low' => 'stock-bar__fill stock-bar--low', + default => 'stock-bar__fill stock-bar--normal', }; -$bandText = static fn (string $band): string => match ($band) { - 'critical' => 'Critique', - 'low' => 'Alerte', - default => 'Normal', + +/** + * Barre de niveau : conteneur + portion remplie (largeur = pct%, couleur = bande). + * La largeur est bornee a 100 pour rester dans le conteneur meme si le depot renvoie + * un pourcentage superieur. Style inline pour la largeur (deja la convention admin). + * + * @param array $row + */ +$renderBar = static function (array $row) use ($esc, $barClass): string { + $pct = (int) ($row['stock_pct'] ?? 0); + $width = max(0, min(100, $pct)); + $band = (string) ($row['stock_band'] ?? 'normal'); + $qty = (int) ($row['stock_quantity'] ?? 0); + $cap = (int) ($row['stock_capacity'] ?? 0); + + $state = match ($band) { + 'critical' => 'critique', + 'low' => 'en alerte', + default => 'au-dessus du seuil', + }; + $html = ''; + $html .= '
' . $pct . '%'; + $html .= '' . $esc((string) $qty) . ' / ' . $esc((string) $cap) . '
'; + + return $html; }; ?> -
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - -
IngredientUniteStockNiveauStatut
Aucun ingredient.
- - / (%) - - - Actif - - Inactif - - - Mouvements - - Reappro - - - Inventaire - - - Modifier -
- - -
- Supprimer - -
+

+ Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une + recette qui passe sous son seuil critique rend les produits qui l utilisent + indisponibles a la commande. Tenez les niveaux a jour pour garder le menu ouvert. +

+ +
+
+ + critiques +
+
+ + en alerte +
+
+ + au-dessus du seuil
+ +
+

A reapprovisionner

+ +
+ Tous les ingredients sont au-dessus de leurs seuils. +
+ +
+ + +
+
+
+ + +
+ +
+ + + Reapprovisionner + +
+ +
+ +
+ +
+

Tous les ingredients

+ +
Aucun ingredient.
+ + + +
diff --git a/src/public/admin/assets/css/admin.css b/src/public/admin/assets/css/admin.css index 7e77e48..d563c17 100644 --- a/src/public/admin/assets/css/admin.css +++ b/src/public/admin/assets/css/admin.css @@ -1813,3 +1813,229 @@ tbody td.mono { gap: 10px; margin-top: 16px; } + +/* --- Stock dashboard (page d'accueil ingredients) --- */ +.stock-explainer { + background: var(--color-yellow-bg); + border: 1px solid var(--color-yellow-soft); + border-radius: var(--radius-lg); + padding: 12px 16px; + font-size: 13px; + line-height: 1.5; + color: var(--color-text-sec); + margin-bottom: 20px; +} + +.stock-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 16px; + margin-bottom: 28px; +} + +.stock-summary__item { + display: flex; + align-items: baseline; + gap: 10px; + background: var(--color-white); + border: 1px solid var(--color-border); + border-left-width: 4px; + border-radius: var(--radius-card); + padding: 18px 22px; + box-shadow: var(--shadow-card); +} + +.stock-summary__item--danger { border-left-color: var(--color-danger); } +.stock-summary__item--warning { border-left-color: var(--color-warning); } +.stock-summary__item--success { border-left-color: var(--color-success); } + +.stock-summary__count { + font-size: 28px; + font-weight: 700; + line-height: 1; + color: var(--color-text); +} + +.stock-summary__item--danger .stock-summary__count { color: var(--color-danger-text); } +.stock-summary__item--warning .stock-summary__count { color: var(--color-warning-text); } +.stock-summary__item--success .stock-summary__count { color: var(--color-success-text); } + +.stock-summary__label { + font-size: 13px; + color: var(--color-text-muted); +} + +.stock-section { + margin-bottom: 32px; +} + +.stock-section__title { + font-size: 16px; + font-weight: 700; + color: var(--color-text); + margin-bottom: 14px; +} + +.stock-empty { + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + padding: 20px; + font-size: 14px; + color: var(--color-text-muted); +} + +.stock-empty--ok { + background: var(--color-success-bg); + border-color: var(--color-success-bg); + color: var(--color-success-text); +} + +/* Barre de niveau : conteneur gris + portion remplie coloree selon la bande. */ +.stock-bar { + height: 8px; + background: var(--color-neutral-bg); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.stock-bar__fill { + display: block; + height: 100%; + border-radius: var(--radius-sm); +} + +.stock-bar--critical { background: var(--color-danger); } +.stock-bar--low { background: var(--color-warning); } +.stock-bar--normal { background: var(--color-success); } + +.stock-bar__meta { + display: flex; + justify-content: space-between; + margin-top: 6px; + font-size: 12px; +} + +.stock-bar__pct { font-weight: 600; color: var(--color-text); } +.stock-bar__qty { color: var(--color-text-muted); } + +/* Section "A reapprovisionner" : cartes mises en avant. */ +.stock-cards { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(min(260px, 100%), 1fr)); + gap: 16px; +} + +.stock-card { + background: var(--color-white); + border: 1px solid var(--color-border); + border-top-width: 3px; + border-radius: var(--radius-card); + padding: 16px 18px; + box-shadow: var(--shadow-card); + display: flex; + flex-direction: column; + gap: 12px; +} + +.stock-card--critical { border-top-color: var(--color-danger); } +.stock-card--low { border-top-color: var(--color-warning); } + +.stock-card__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.stock-card__name { + font-size: 15px; + font-weight: 600; + color: var(--color-text); +} + +.stock-card__unit { + font-size: 12px; + color: var(--color-text-muted); + margin-left: 6px; +} + +.stock-card__action { + height: 44px; + justify-content: center; + width: 100%; +} + +/* Section "Tous les ingredients" : liste calme, actions secondaires discretes. */ +.stock-list { + list-style: none; + background: var(--color-white); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); + overflow: hidden; +} + +.stock-list__row { + display: grid; + grid-template-columns: 1fr 200px auto; + align-items: center; + gap: 20px; + padding: 14px 18px; + border-bottom: 1px solid var(--color-border); +} + +.stock-list__row:last-child { + border-bottom: none; +} + +.stock-list__main { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.stock-list__name { + font-weight: 600; + color: var(--color-text); +} + +.stock-list__unit { + font-size: 12px; + color: var(--color-text-muted); +} + +.stock-list__actions { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.stock-list__crud { + display: inline-flex; + align-items: center; + gap: 6px; + padding-left: 6px; + margin-left: 2px; + border-left: 1px solid var(--color-border); +} + +.stock-list__inline-form { + display: inline; +} + +@media (max-width: 900px) { + .stock-summary { + grid-template-columns: 1fr; + } + .stock-list__row { + grid-template-columns: 1fr; + gap: 10px; + } + .stock-list__actions { + justify-content: flex-start; + } +} diff --git a/tests/Unit/Admin/IngredientControllerTest.php b/tests/Unit/Admin/IngredientControllerTest.php index 92af9ea..6601e3d 100644 --- a/tests/Unit/Admin/IngredientControllerTest.php +++ b/tests/Unit/Admin/IngredientControllerTest.php @@ -198,6 +198,64 @@ final class IngredientControllerTest extends TestCase self::assertStringContainsString('Alerte', $response->body()); } + public function testIndexShowsBusinessExplainerBanner(): void + { + // Le bandeau explique le lien metier stock -> disponibilite borne (RG-T21), + // l'info qui manquait dans l'ancien tableau brut. + $db = $this->permittedDb(); + $db->ingredientsRows = [$this->ingredient()]; + + $body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body(); + + self::assertStringContainsString('stock-explainer', $body); + self::assertStringContainsString('borne', $body); + } + + public function testIndexPromotesLowAndCriticalIntoRestockSection(): void + { + // Un ingredient critique (3% < seuil 5) doit apparaitre dans la section + // "A reapprovisionner" mise en avant, pas seulement dans la liste calme. + $db = $this->permittedDb(); + $db->ingredientsRows = [$this->ingredient(['name' => 'Buns', 'stock_quantity' => 3])]; + + $body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body(); + + self::assertStringContainsString('A reapprovisionner', $body); + self::assertStringContainsString('stock-section--restock', $body); + self::assertStringContainsString('Buns', $body); + self::assertStringContainsString('Critique', $body); + } + + public function testIndexShowsPositiveEmptyStateWhenNothingLow(): void + { + // Tous au-dessus des seuils -> etat vide positif dans la section restock. + $db = $this->permittedDb(); + $db->ingredientsRows = [$this->ingredient(['stock_quantity' => 100])]; // 100% -> normal + + $body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body(); + + self::assertStringContainsString('au-dessus de leurs seuils', $body); + } + + public function testIndexCountsIngredientsPerBand(): void + { + // Resume en haut : 1 critique (3%), 1 alerte (8%), 1 au-dessus (100%). + $db = $this->permittedDb(); + $db->ingredientsRows = [ + $this->ingredient(['name' => 'Buns', 'stock_quantity' => 3]), + $this->ingredient(['name' => 'Cheddar', 'stock_quantity' => 8]), + $this->ingredient(['name' => 'Salade', 'stock_quantity' => 100]), + ]; + + $body = $this->controller($this->get('/admin/ingredients'), $db)->index()->body(); + + // Chaque compteur est verrouille a SON libelle (sinon une regex generique + // passerait meme avec les trois compteurs inverses). + self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*critiques/', $body); + self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*en alerte/', $body); + self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*au-dessus du seuil/', $body); + } + public function testIndexForbiddenWithoutStockRead(): void { $db = $this->permittedDb(); -- 2.45.3