feat(admin): tableau de bord statistiques (catalogue + sante stock RG-T21) (P3) (#37)
All checks were successful
CI / secret-scan (push) Successful in 8s
CI / php-lint (push) Successful in 19s
CI / static-tests (push) Successful in 44s
CI / js-tests (push) Successful in 18s
CI / auto-merge (push) Has been skipped

This commit is contained in:
Corentin JOGUET 2026-06-17 12:37:58 +02:00
parent 1ecd78324c
commit 9c2844c116
8 changed files with 507 additions and 0 deletions

View file

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace App\Catalogue;
use App\Core\DatabaseInterface;
/**
* Agregats de pilotage du back-office (tableau de bord, permission stats.read).
* KPIs sur les donnees DISPONIBLES en P3 : compteurs de catalogue et sante du
* stock (bandes RG-T21). Les KPIs de vente (CA, volumes) dependent du domaine
* commande (P4) et sont hors perimetre ici.
*
* Non `final` : les tests sous-classent pour stubber les agregats sans base.
*/
class StatsRepository
{
public function __construct(private readonly DatabaseInterface $db)
{
}
/**
* Compteurs de catalogue : total + sous-ensemble actif/disponible par entite.
* SUM(bool) compte les lignes verifiant le predicat (COALESCE pour table vide).
* Chaque entree porte 'total' + la cle de sous-ensemble ('available' pour
* product/menu, 'active' pour category/ingredient).
*
* @return array<string, array<string, int>>
*/
public function counts(): array
{
return [
'products' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_available = 1), 0) AS n FROM product', 'available'),
'categories' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_active = 1), 0) AS n FROM category', 'active'),
'menus' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_available = 1), 0) AS n FROM menu', 'available'),
'ingredients' => $this->pair('SELECT COUNT(*) AS total, COALESCE(SUM(is_active = 1), 0) AS n FROM ingredient', 'active'),
];
}
/**
* @return array<string, int>
*/
private function pair(string $sql, string $key): array
{
$row = $this->db->fetch($sql) ?? [];
return ['total' => (int) ($row['total'] ?? 0), $key => (int) ($row['n'] ?? 0)];
}
/**
* Sante du stock : repartition des ingredients ACTIFS par bande (RG-T21, via
* IngredientRepository::stockBand = source unique de la derivation) + liste
* d'alerte (bandes low/critical), triee du plus critique au moins critique.
*
* @return array{active_total:int, bands:array{normal:int,low:int,critical:int}, alerts:list<array{name:string,stock_pct:int,stock_band:string}>}
*/
public function stockHealth(): array
{
$rows = $this->db->fetchAll(
'SELECT name, stock_quantity, stock_capacity, low_stock_pct, critical_stock_pct '
. 'FROM ingredient WHERE is_active = 1 ORDER BY name',
);
$bands = ['normal' => 0, 'low' => 0, 'critical' => 0];
$alerts = [];
foreach ($rows as $r) {
$qty = (int) ($r['stock_quantity'] ?? 0);
$cap = (int) ($r['stock_capacity'] ?? 0);
$band = IngredientRepository::stockBand(
$qty,
$cap,
(int) ($r['low_stock_pct'] ?? 0),
(int) ($r['critical_stock_pct'] ?? 0),
);
$bands[$band]++;
if ($band !== 'normal') {
$alerts[] = [
'name' => (string) ($r['name'] ?? ''),
'stock_pct' => IngredientRepository::stockPct($qty, $cap),
'stock_band' => $band,
];
}
}
// Plus critique (pourcentage le plus bas) en tete.
usort($alerts, static fn (array $a, array $b): int => $a['stock_pct'] <=> $b['stock_pct']);
return ['active_total' => count($rows), 'bands' => $bands, 'alerts' => $alerts];
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Controllers;
use App\Catalogue\StatsRepository;
use App\Core\Response;
/**
* Tableau de bord statistiques (mlt domaine 11). GET /admin/stats, permission
* stats.read (landing par defaut du role manager, cf. seed role.default_route).
* En P3, les KPIs portent sur les donnees disponibles : compteurs de catalogue
* et sante du stock (RG-T21). Les KPIs de vente (CA, volumes) viendront avec le
* domaine commande (P4).
*
* Non `final` : les tests sous-classent (seam db()/statsRepository()).
*/
class StatsController extends AdminController
{
/**
* @param array<string, string> $params
*/
public function index(array $params = []): Response
{
$guard = $this->guard('stats.read');
if ($guard instanceof Response) {
return $guard;
}
return $this->adminView('admin/stats/index', [
'title' => 'Statistiques - Wakdo Admin',
'activeNav' => 'stats',
'counts' => $this->statsRepository()->counts(),
'stock' => $this->statsRepository()->stockHealth(),
], $guard);
}
protected function statsRepository(): StatsRepository
{
return new StatsRepository($this->db());
}
}

View file

@ -118,6 +118,13 @@ $navClass = static function (string $code, string $current): string {
</div> </div>
<?php endif; ?> <?php endif; ?>
<?php if ($can('stats.read')): ?>
<div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>
</div>
<?php endif; ?>
<?php /* <?php /*
Items de nav volontairement absents tant que leur page n'existe pas Items de nav volontairement absents tant que leur page n'existe pas
(un lien vers une route non enregistree renvoie un 404). A reactiver (un lien vers une route non enregistree renvoie un 404). A reactiver

View file

@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
/**
* Tableau de bord statistiques (stats.read), injecte dans admin/layout.php.
* KPIs disponibles en P3 : compteurs de catalogue + sante du stock (RG-T21).
* Les KPIs de vente (CA, volumes) arrivent avec le domaine commande (P4).
*
* @var array{products:array{total:int,available:int}, categories:array{total:int,active:int}, menus:array{total:int,available:int}, ingredients:array{total:int,active:int}} $counts
* @var array{active_total:int, bands:array{normal:int,low:int,critical:int}, alerts:list<array{name:string,stock_pct:int,stock_band:string}>} $stock
*/
$esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES, 'UTF-8');
/** @var array<string, array<string, int>> $c */
$c = isset($counts) && is_array($counts) ? $counts : [];
/** @var array<string, mixed> $s */
$s = isset($stock) && is_array($stock) ? $stock : ['active_total' => 0, 'bands' => ['normal' => 0, 'low' => 0, 'critical' => 0], 'alerts' => []];
$bands = is_array($s['bands'] ?? null) ? $s['bands'] : ['normal' => 0, 'low' => 0, 'critical' => 0];
/** @var list<array<string, mixed>> $alerts */
$alerts = is_array($s['alerts'] ?? null) ? $s['alerts'] : [];
$bandLabel = static fn (string $b): string => match ($b) {
'critical' => 'Critique',
'low' => 'Alerte',
default => 'Normal',
};
$bandPill = static fn (string $b): string => match ($b) {
'critical' => 'pill-danger',
'low' => 'pill-warning',
default => 'pill-success',
};
/** @var list<array{key:string, label:string, sub:string}> $cards */
$cards = [
['key' => 'products', 'label' => 'Produits', 'sub' => 'available'],
['key' => 'menus', 'label' => 'Menus', 'sub' => 'available'],
['key' => 'categories', 'label' => 'Categories', 'sub' => 'active'],
['key' => 'ingredients', 'label' => 'Ingredients', 'sub' => 'active'],
];
?>
<div class="page-header">
<div>
<h1 class="page-title">Statistiques</h1>
<p class="page-subtitle">Sante du catalogue et du stock. Les indicateurs de vente arriveront avec les commandes (P4).</p>
</div>
</div>
<div class="stats-cards">
<?php foreach ($cards as $card): ?>
<?php
$entity = is_array($c[$card['key']] ?? null) ? $c[$card['key']] : [];
$total = (int) ($entity['total'] ?? 0);
$sub = (int) ($entity[$card['sub']] ?? 0);
$subLabel = $card['sub'] === 'available' ? 'disponibles' : 'actifs';
?>
<div class="stat-card">
<div class="stat-card__value"><?= $esc($total) ?></div>
<div class="stat-card__label"><?= $esc($card['label']) ?></div>
<div class="stat-card__sub muted"><?= $esc($sub) ?> <?= $esc($subLabel) ?></div>
</div>
<?php endforeach; ?>
</div>
<div class="page-header">
<div>
<h2 class="page-title">Sante du stock</h2>
<p class="page-subtitle"><?= $esc((int) ($s['active_total'] ?? 0)) ?> ingredients actifs — normal <?= $esc((int) $bands['normal']) ?>, alerte <?= $esc((int) $bands['low']) ?>, critique <?= $esc((int) $bands['critical']) ?> (RG-T21).</p>
</div>
</div>
<div class="table-container">
<div class="table-wrapper">
<table>
<thead>
<tr>
<th>Ingredient</th>
<th>Stock</th>
<th>Etat</th>
</tr>
</thead>
<tbody>
<?php if ($alerts === []): ?>
<tr><td colspan="3" class="muted">Aucun ingredient en alerte ou rupture. Stock sain.</td></tr>
<?php endif; ?>
<?php foreach ($alerts as $a): ?>
<?php $band = (string) ($a['stock_band'] ?? 'normal'); ?>
<tr>
<td class="fw-600"><?= $esc($a['name'] ?? '') ?></td>
<td><?= $esc((int) ($a['stock_pct'] ?? 0)) ?>%</td>
<td>
<span class="pill <?= $esc($bandPill($band)) ?>" data-band="<?= $esc($band) ?>"><?= $esc($bandLabel($band)) ?></span>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>

View file

@ -1173,3 +1173,38 @@ tbody td.mono {
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: var(--color-text-muted); background: var(--color-text-muted);
} }
/* ============================================================
Statistiques cartes KPI (tableau de bord stats.read)
============================================================ */
.stats-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
background: var(--color-white);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
}
.stat-card__value {
font-size: 2rem;
font-weight: 700;
color: var(--color-text);
line-height: 1.1;
}
.stat-card__label {
font-weight: 600;
color: var(--color-text);
margin-top: 0.25rem;
}
.stat-card__sub {
font-size: 0.875rem;
margin-top: 0.125rem;
}

View file

@ -22,6 +22,7 @@ use App\Controllers\MenuController;
use App\Controllers\PasswordResetController; use App\Controllers\PasswordResetController;
use App\Controllers\ProductController; use App\Controllers\ProductController;
use App\Controllers\ProfileController; use App\Controllers\ProfileController;
use App\Controllers\StatsController;
use App\Core\Autoloader; use App\Core\Autoloader;
use App\Core\Config; use App\Core\Config;
use App\Core\Database; use App\Core\Database;
@ -69,6 +70,9 @@ try {
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard. // Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
$router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']); $router->add('GET', '/admin/dashboard', [DashboardController::class, 'index']);
// Tableau de bord statistiques (stats.read) : landing du role manager. KPIs
// catalogue + sante stock (RG-T21) ; KPIs de vente avec les commandes (P4).
$router->add('GET', '/admin/stats', [StatsController::class, 'index']);
// CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active. // CRUD Categories (permission category.manage). Pas de suppression dure : toggle is_active.
$router->add('GET', '/admin/categories', [CategoryController::class, 'index']); $router->add('GET', '/admin/categories', [CategoryController::class, 'index']);

View file

@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Tests\Integration;
use PHPUnit\Framework\TestCase;
use Throwable;
use App\Catalogue\IngredientRepository;
use App\Catalogue\StatsRepository;
use App\Core\Config;
use App\Core\Database;
/**
* StatsRepository contre une vraie MariaDB (schema migre + seede). Auto-skip si
* WAKDO_DB_TESTS != 1. Verifie les compteurs catalogue et la sante stock (bandes
* RG-T21) sur des donnees reelles. Ingredient jetable (it-stat-*) nettoye en
* tearDown (mouvements d'abord, FK RESTRICT).
*/
final class StatsRepositoryDbTest extends TestCase
{
private Database $db;
private string $ing = '';
protected function setUp(): void
{
if (getenv('WAKDO_DB_TESTS') !== '1') {
self::markTestSkipped('Tests DB desactives (definir WAKDO_DB_TESTS=1 + DB_*).');
}
$this->db = new Database(new Config());
try {
$this->db->fetch('SELECT 1');
} catch (Throwable $exception) {
self::markTestSkipped('Base injoignable: ' . $exception->getMessage());
}
$this->ing = 'it-stat-' . bin2hex(random_bytes(4));
}
protected function tearDown(): void
{
if ($this->ing === '') {
return;
}
$id = (int) ($this->db->fetch('SELECT id FROM ingredient WHERE name = :n', ['n' => $this->ing])['id'] ?? 0);
if ($id > 0) {
$this->db->execute('DELETE FROM stock_movement WHERE ingredient_id = :id', ['id' => $id]);
$this->db->execute('DELETE FROM ingredient WHERE id = :id', ['id' => $id]);
}
}
public function testCountsReflectSeededCatalogue(): void
{
$stats = new StatsRepository($this->db);
$counts = $stats->counts();
// Le seed pose un catalogue non vide (9 categories, 53 produits, 13 menus).
self::assertGreaterThan(0, $counts['categories']['total']);
self::assertGreaterThan(0, $counts['products']['total']);
self::assertGreaterThan(0, $counts['menus']['total']);
// 'available'/'active' borne par 'total'.
self::assertLessThanOrEqual($counts['products']['total'], $counts['products']['available']);
self::assertLessThanOrEqual($counts['categories']['total'], $counts['categories']['active']);
}
public function testStockHealthClassifiesCriticalIngredient(): void
{
$ingredients = new IngredientRepository($this->db);
// Ingredient actif sous le seuil critique (2/100 <= 5%).
$ingredients->create([
'name' => $this->ing, 'unit' => 'portion', 'stock_quantity' => 2, 'stock_capacity' => 100,
'pack_size' => 1, 'pack_label' => null, 'low_stock_pct' => 10, 'critical_stock_pct' => 5, 'is_active' => 1,
]);
$health = (new StatsRepository($this->db))->stockHealth();
self::assertGreaterThanOrEqual(1, $health['bands']['critical']);
// L'ingredient apparait dans la liste d'alerte (bas/critique).
$names = array_map(static fn (array $a): string => (string) $a['name'], $health['alerts']);
self::assertContains($this->ing, $names);
// Le total des bandes = nb d'ingredients actifs.
$sum = $health['bands']['normal'] + $health['bands']['low'] + $health['bands']['critical'];
self::assertSame($health['active_total'], $sum);
}
}

View file

@ -0,0 +1,144 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Admin;
use PHPUnit\Framework\TestCase;
use App\Auth\SessionManager;
use App\Catalogue\StatsRepository;
use App\Controllers\StatsController;
use App\Core\Config;
use App\Core\Database;
use App\Core\DatabaseInterface;
use App\Core\Request;
use App\Tests\Support\FakeDatabase;
/**
* Stub de StatsRepository : KPIs canned, sans base. Permet de tester le rendu du
* controleur independamment des requetes d'agregation (couvertes par le DbTest).
*/
final class StubStatsRepository extends StatsRepository
{
public function counts(): array
{
return [
'products' => ['total' => 53, 'available' => 50],
'categories' => ['total' => 9, 'active' => 9],
'menus' => ['total' => 13, 'available' => 12],
'ingredients' => ['total' => 7, 'active' => 6],
];
}
public function stockHealth(): array
{
return [
'active_total' => 6,
'bands' => ['normal' => 4, 'low' => 1, 'critical' => 1],
'alerts' => [
['name' => 'Cheddar', 'stock_pct' => 3, 'stock_band' => 'critical'],
['name' => 'Cornichon', 'stock_pct' => 8, 'stock_band' => 'low'],
],
];
}
}
final class TestStatsController extends StatsController
{
public function __construct(
Request $request,
Config $config,
Database $database,
private readonly SessionManager $testSession,
private readonly FakeDatabase $fakeDb,
) {
parent::__construct($request, $config, $database);
}
protected function sessionManager(): SessionManager
{
return $this->testSession;
}
protected function db(): DatabaseInterface
{
return $this->fakeDb;
}
protected function statsRepository(): StatsRepository
{
return new StubStatsRepository($this->fakeDb);
}
}
final class StatsControllerTest extends TestCase
{
/** @var list<string> */
private array $touchedKeys = [];
private SessionManager $session;
protected function setUp(): void
{
$this->setEnv('SESSION_LIFETIME_IDLE', '14400');
$this->setEnv('SESSION_LIFETIME_ABSOLUTE', '36000');
$this->session = new SessionManager(new Config(), true);
$now = time();
$this->session->set('user_id', 1);
$this->session->set('role_id', 2);
$this->session->set('logged_in_at', $now - 100);
$this->session->set('last_activity', $now - 50);
}
protected function tearDown(): void
{
foreach ($this->touchedKeys as $key) {
putenv($key);
}
$this->touchedKeys = [];
}
private function setEnv(string $key, string $value): void
{
$this->touchedKeys[] = $key;
putenv($key . '=' . $value);
}
private function permittedDb(): FakeDatabase
{
$db = new FakeDatabase();
$db->guardUserRow = ['is_active' => 1];
$db->userDisplayRow = ['first_name' => 'Manon', 'last_name' => 'G', 'role_label' => 'Manager'];
$db->canResult = true;
$db->permissionCodes = ['stats.read'];
return $db;
}
private function controller(FakeDatabase $db): TestStatsController
{
$request = new Request('GET', '/admin/stats', [], [], '', '203.0.113.5');
return new TestStatsController($request, new Config(), new Database(new Config()), $this->session, $db);
}
public function testRequiresStatsRead(): void
{
$db = $this->permittedDb();
$db->canResult = false;
self::assertSame(403, $this->controller($db)->index()->status());
}
public function testRendersCatalogueCountsAndStockAlerts(): void
{
$db = $this->permittedDb();
$response = $this->controller($db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Statistiques', $body);
self::assertStringContainsString('53', $body); // compteur produits
self::assertStringContainsString('Cheddar', $body); // alerte stock critique
self::assertStringContainsString('critical', $body); // bande
}
}