feat(back-office): page Stock en tableau de bord (alertes + reappro en avant) (#105)
This commit is contained in:
parent
9bdd53120c
commit
2fe192452d
4 changed files with 453 additions and 75 deletions
|
|
@ -47,10 +47,22 @@ class IngredientController extends AdminController
|
||||||
return $guard;
|
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', [
|
return $this->adminView('admin/ingredients/index', [
|
||||||
'title' => 'Stock - Wakdo Admin',
|
'title' => 'Stock - Wakdo Admin',
|
||||||
'activeNav' => 'stock',
|
'activeNav' => 'stock',
|
||||||
'ingredients' => $this->ingredientRepository()->all(),
|
'ingredients' => $ingredients,
|
||||||
|
'bandCounts' => $counts,
|
||||||
'canManage' => $this->may($guard, 'ingredient.manage'),
|
'canManage' => $this->may($guard, 'ingredient.manage'),
|
||||||
'canRestock' => $this->may($guard, 'stock.manage'),
|
'canRestock' => $this->may($guard, 'stock.manage'),
|
||||||
'canCount' => $this->may($guard, 'stock.count'),
|
'canCount' => $this->may($guard, 'stock.count'),
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,15 @@
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Liste du stock (READ_STOCK 9.3), injectee dans admin/layout.php. Affiche le
|
* Tableau de bord stock (READ_STOCK 9.3), injecte dans admin/layout.php. Oriente
|
||||||
* pourcentage et la bande calcules (RG-2) ; les liens d'action sont conditionnes
|
* usage quotidien : on met en avant ce qui est bas a reapprovisionner, le CRUD de
|
||||||
* aux permissions (la garde reelle reste par-route). Texte echappe.
|
* 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<int, array<string, mixed>> $ingredients
|
||||||
|
* @var array<string, int> $bandCounts
|
||||||
* @var bool $canManage
|
* @var bool $canManage
|
||||||
* @var bool $canRestock
|
* @var bool $canRestock
|
||||||
* @var bool $canCount
|
* @var bool $canCount
|
||||||
|
|
@ -16,94 +20,172 @@ declare(strict_types=1);
|
||||||
|
|
||||||
/** @var array<int, array<string, mixed>> $rows */
|
/** @var array<int, array<string, mixed>> $rows */
|
||||||
$rows = isset($ingredients) && is_array($ingredients) ? $ingredients : [];
|
$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');
|
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
|
||||||
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
|
||||||
$manage = (bool) ($canManage ?? false);
|
$manage = (bool) ($canManage ?? false);
|
||||||
$restock = (bool) ($canRestock ?? false);
|
$restock = (bool) ($canRestock ?? false);
|
||||||
$count = (bool) ($canCount ?? false);
|
$count = (bool) ($canCount ?? false);
|
||||||
|
|
||||||
$bandLabel = static fn (string $band): string => match ($band) {
|
$nCritical = (int) ($counts['critical'] ?? 0);
|
||||||
'critical' => 'pill pill-danger',
|
$nLow = (int) ($counts['low'] ?? 0);
|
||||||
'low' => 'pill pill-warning',
|
$nNormal = (int) ($counts['normal'] ?? 0);
|
||||||
default => 'pill pill-success',
|
|
||||||
|
// 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',
|
* Barre de niveau : conteneur + portion remplie (largeur = pct%, couleur = bande).
|
||||||
default => 'Normal',
|
* 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 class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="page-title">Stock</h1>
|
<h1 class="page-title">Stock des ingredients</h1>
|
||||||
<p class="page-subtitle">Ingredients, niveaux de stock et mouvements</p>
|
<p class="page-subtitle">Ce qui est bas a reapprovisionner, en un coup d oeil</p>
|
||||||
</div>
|
</div>
|
||||||
<?php if ($manage): ?>
|
<?php if ($manage): ?>
|
||||||
<div class="page-actions">
|
<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>
|
</div>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-container">
|
<p class="stock-explainer">
|
||||||
<div class="table-wrapper">
|
Le stock pilote ce qui est commandable sur la borne. Un ingredient requis par une
|
||||||
<table>
|
recette qui passe sous son seuil critique rend les produits qui l utilisent
|
||||||
<thead>
|
indisponibles a la commande. Tenez les niveaux a jour pour garder le menu ouvert.
|
||||||
<tr>
|
</p>
|
||||||
<th>Ingredient</th>
|
|
||||||
<th>Unite</th>
|
<div class="stock-summary">
|
||||||
<th>Stock</th>
|
<div class="stock-summary__item stock-summary__item--danger">
|
||||||
<th>Niveau</th>
|
<span class="stock-summary__count"><?= $nCritical ?></span>
|
||||||
<th>Statut</th>
|
<span class="stock-summary__label">critiques</span>
|
||||||
<th style="width:280px;"></th>
|
</div>
|
||||||
</tr>
|
<div class="stock-summary__item stock-summary__item--warning">
|
||||||
</thead>
|
<span class="stock-summary__count"><?= $nLow ?></span>
|
||||||
<tbody>
|
<span class="stock-summary__label">en alerte</span>
|
||||||
<?php if ($rows === []): ?>
|
</div>
|
||||||
<tr><td colspan="6" class="muted">Aucun ingredient.</td></tr>
|
<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; ?>
|
<?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 foreach ($rows as $row): ?>
|
||||||
<?php
|
<?php
|
||||||
$id = (int) ($row['id'] ?? 0);
|
$id = (int) ($row['id'] ?? 0);
|
||||||
$active = (int) ($row['is_active'] ?? 0) === 1;
|
$active = (int) ($row['is_active'] ?? 0) === 1;
|
||||||
$band = (string) ($row['stock_band'] ?? 'normal');
|
|
||||||
$pct = (int) ($row['stock_pct'] ?? 0);
|
|
||||||
?>
|
?>
|
||||||
<tr>
|
<li class="stock-list__row">
|
||||||
<td class="fw-600"><?= $esc($row['name'] ?? '') ?></td>
|
<div class="stock-list__main">
|
||||||
<td class="muted"><?= $esc($row['unit'] ?? '') ?></td>
|
<span class="stock-list__name"><?= $esc($row['name'] ?? '') ?></span>
|
||||||
<td>
|
<span class="stock-list__unit"><?= $esc($row['unit'] ?? '') ?></span>
|
||||||
<?= $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>
|
|
||||||
<?php if ($active): ?>
|
<?php if ($active): ?>
|
||||||
<span class="pill pill-success">Actif</span>
|
<span class="pill pill-success">Actif</span>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<span class="pill pill-neutral">Inactif</span>
|
<span class="pill pill-neutral">Inactif</span>
|
||||||
<?php endif; ?>
|
<?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; ?>
|
|
||||||
<?php if ($count): ?>
|
|
||||||
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/inventory">Inventaire</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
<?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;">
|
|
||||||
<input type="hidden" name="_csrf" value="<?= $csrf ?>">
|
|
||||||
<button class="btn btn-secondary" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
|
|
||||||
</form>
|
|
||||||
<a class="btn btn-secondary" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
|
|
||||||
<?php endif; ?>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<?php endforeach; ?>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="stock-list__bar"><?= $renderBar($row) ?></div>
|
||||||
|
<div class="stock-list__actions">
|
||||||
|
<?php if ($count): ?>
|
||||||
|
<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): ?>
|
||||||
|
<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-ghost btn-sm" type="submit"><?= $active ? 'Desactiver' : 'Reactiver' ?></button>
|
||||||
|
</form>
|
||||||
|
<a class="btn btn-ghost btn-sm" href="/admin/ingredients/<?= $id ?>/delete">Supprimer</a>
|
||||||
|
</span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
<?php endif; ?>
|
||||||
|
</section>
|
||||||
|
|
|
||||||
|
|
@ -1813,3 +1813,229 @@ tbody td.mono {
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-top: 16px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -198,6 +198,64 @@ final class IngredientControllerTest extends TestCase
|
||||||
self::assertStringContainsString('Alerte', $response->body());
|
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
|
public function testIndexForbiddenWithoutStockRead(): void
|
||||||
{
|
{
|
||||||
$db = $this->permittedDb();
|
$db = $this->permittedDb();
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue