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>
<?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 /*
Items de nav volontairement absents tant que leur page n'existe pas
(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 {
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\ProductController;
use App\Controllers\ProfileController;
use App\Controllers\StatsController;
use App\Core\Autoloader;
use App\Core\Config;
use App\Core\Database;
@ -69,6 +70,9 @@ try {
// Back-office (P3) : pages rendues serveur sous /admin, gardees par SessionGuard.
$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.
$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
}
}