feat(back-office): page Stock en tableau de bord (alertes + reappro en avant) (#105)
All checks were successful
CI / secret-scan (push) Successful in 19s
CI / php-lint (push) Successful in 51s
CI / static-tests (push) Successful in 1m35s
CI / js-tests (push) Successful in 41s

This commit is contained in:
Corentin JOGUET 2026-06-24 14:44:25 +02:00
parent 9bdd53120c
commit 2fe192452d
4 changed files with 453 additions and 75 deletions

View file

@ -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'),

View file

@ -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<int, array<string, mixed>> $ingredients
* @var array<string, int> $bandCounts
* @var bool $canManage
* @var bool $canRestock
* @var bool $canCount
@ -16,94 +20,172 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $rows */
$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
/** @var array<string, int> $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<string, mixed> $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 = '<div class="stock-bar" role="img" aria-label="Niveau de stock ' . $pct . ' pourcent, etat ' . $state . '">';
$html .= '<span class="' . $esc($barClass($band)) . '" style="width:' . $width . '%"></span>';
$html .= '</div>';
$html .= '<div class="stock-bar__meta"><span class="stock-bar__pct">' . $pct . '%</span>';
$html .= '<span class="stock-bar__qty">' . $esc((string) $qty) . ' / ' . $esc((string) $cap) . '</span></div>';
return $html;
};
?>
<div class="page-header">
<div>
<h1 class="page-title">Stock</h1>
<p class="page-subtitle">Ingredients, niveaux de stock et mouvements</p>
<h1 class="page-title">Stock des ingredients</h1>
<p class="page-subtitle">Ce qui est bas a reapprovisionner, en un coup d oeil</p>
</div>
<?php if ($manage): ?>
<div class="page-actions">
<a class="btn btn-primary" href="/admin/ingredients/new">Nouvel ingredient</a>
<a class="btn btn-secondary" href="/admin/ingredients/new">Nouvel ingredient</a>
</div>
<?php endif; ?>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Unite</th>
<th>Stock</th>
<th>Niveau</th>
<th>Statut</th>
<th style="width:280px;"></th>
</tr>
</thead>
<tbody>
<?php if ($rows === []): ?>
<tr><td colspan="6" class="muted">Aucun ingredient.</td></tr>
<p class="stock-explainer">
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.
</p>
<div class="stock-summary">
<div class="stock-summary__item stock-summary__item--danger">
<span class="stock-summary__count"><?= $nCritical ?></span>
<span class="stock-summary__label">critiques</span>
</div>
<div class="stock-summary__item stock-summary__item--warning">
<span class="stock-summary__count"><?= $nLow ?></span>
<span class="stock-summary__label">en alerte</span>
</div>
<div class="stock-summary__item stock-summary__item--success">
<span class="stock-summary__count"><?= $nNormal ?></span>
<span class="stock-summary__label">au-dessus du seuil</span>
</div>
</div>
<section class="stock-section stock-section--restock">
<h2 class="stock-section__title">A reapprovisionner</h2>
<?php if ($toRestock === []): ?>
<div class="stock-empty stock-empty--ok">
Tous les ingredients sont au-dessus de leurs seuils.
</div>
<?php else: ?>
<div class="stock-cards">
<?php foreach ($toRestock as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$band = (string) ($row['stock_band'] ?? 'normal');
$bandPill = $band === 'critical' ? 'pill pill-danger' : 'pill pill-warning';
$bandText = $band === 'critical' ? 'Critique' : 'Alerte';
?>
<div class="stock-card stock-card--<?= $esc($band) ?>">
<div class="stock-card__head">
<div>
<span class="stock-card__name"><?= $esc($row['name'] ?? '') ?></span>
<span class="stock-card__unit"><?= $esc($row['unit'] ?? '') ?></span>
</div>
<span class="<?= $bandPill ?>"><?= $bandText ?></span>
</div>
<?= $renderBar($row) ?>
<?php if ($restock): ?>
<a class="btn btn-primary stock-card__action" href="/admin/ingredients/<?= $id ?>/restock">Reapprovisionner</a>
<?php endif; ?>
</div>
<?php endforeach; ?>
</div>
<?php endif; ?>
</section>
<section class="stock-section">
<h2 class="stock-section__title">Tous les ingredients</h2>
<?php if ($rows === []): ?>
<div class="stock-empty">Aucun ingredient.</div>
<?php else: ?>
<ul class="stock-list">
<?php foreach ($rows as $row): ?>
<?php
$id = (int) ($row['id'] ?? 0);
$active = (int) ($row['is_active'] ?? 0) === 1;
$band = (string) ($row['stock_band'] ?? 'normal');
$pct = (int) ($row['stock_pct'] ?? 0);
?>
<tr>
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
<td class="muted"><?= $esc($row['unit'] ?? '') ?></td>
<td>
<?= $esc((string) ((int) ($row['stock_quantity'] ?? 0))) ?>
<span class="muted">/ <?= $esc((string) ((int) ($row['stock_capacity'] ?? 0))) ?> (<?= $pct ?>%)</span>
</td>
<td><span class="<?= $bandLabel($band) ?>"><?= $bandText($band) ?></span></td>
<td>
<li class="stock-list__row">
<div class="stock-list__main">
<span class="stock-list__name"><?= $esc($row['name'] ?? '') ?></span>
<span class="stock-list__unit"><?= $esc($row['unit'] ?? '') ?></span>
<?php if ($active): ?>
<span class="pill pill-success">Actif</span>
<?php else: ?>
<span class="pill pill-neutral">Inactif</span>
<?php endif; ?>
</td>
<td>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($restock): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/restock">Reappro</a>
<?php endif; ?>
</div>
<div class="stock-list__bar"><?= $renderBar($row) ?></div>
<div class="stock-list__actions">
<?php if ($count): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<a class="btn btn-secondary btn-sm" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
<?php endif; ?>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/movements">Mouvements</a>
<?php if ($manage): ?>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" style="display:inline;">
<span class="stock-list__crud">
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/edit">Modifier</a>
<form method="post" action="/admin/ingredients/<?= $id ?>/toggle" class="stock-list__inline-form">
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
<button class="btn btn-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
<button class="btn btn-ghost btn-sm" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
</form>
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
</span>
<?php endif; ?>
</td>
</tr>
</div>
</li>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
</ul>
<?php endif; ?>
</section>

View file

@ -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;
}
}

View file

@ -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*<span class="stock-summary__label">critiques/', $body);
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">en alerte/', $body);
self::assertMatchesRegularExpression('/stock-summary__count">1<\/span>\s*<span class="stock-summary__label">au-dessus du seuil/', $body);
}
public function testIndexForbiddenWithoutStockRead(): void
{
$db = $this->permittedDb();