feat(admin): ecran Roles humanise (francais, droits groupes, listes deroulantes)
All checks were successful
CI / php-lint (push) Successful in 39s
CI / php-lint (pull_request) Successful in 28s
CI / secret-scan (push) Successful in 19s
CI / static-tests (push) Successful in 2m50s
CI / js-tests (push) Successful in 45s
CI / secret-scan (pull_request) Successful in 13s
CI / static-tests (pull_request) Successful in 1m20s
CI / js-tests (pull_request) Successful in 37s

Le formulaire et la liste des roles etaient tres techniques (libelles anglais, codes
de permission bruts, enums kiosk/counter/drive, 'route par defaut (landing)'). Refonte
de presentation (option a, la base garde les codes) :
- Champs relabeles + aides : Nom du role, Code interne, Page d'accueil (liste deroulante
  de pages), Canal de commande (Borne/Comptoir/Drive), Canaux visibles.
- Droits d'acces : matrice regroupee par domaine (Produits/Menus/Stock/Commandes/Comptes/
  Roles & statistiques...), libelles francais (Voir/Creer/Modifier/Supprimer...), codes masques.
- Liste des roles : entetes + valeurs (page d'accueil, canal) en clair.
- admin.css : .perm-grid/.perm-group + reset fieldset/legend.
Noms de champs postes inchanges (contrat serveur intact). PHPUnit 301 + PHPStan L6 verts.
This commit is contained in:
Imugiii 2026-06-18 11:32:50 +00:00
parent 05eca6aea2
commit 2e1d2e3126
3 changed files with 189 additions and 45 deletions

View file

@ -9,6 +9,10 @@ declare(strict_types=1);
* donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13). * donc pas de `name[]` ni de JS. Toute soumission exige le PIN equipier (RG-T13).
* Le `code` est editable a la creation, fige a l'edition (immuable). * Le `code` est editable a la creation, fige a l'edition (immuable).
* *
* Presentation humanisee (option a) : les permissions sont regroupees par domaine
* et libellees en francais ICI (la base reste la source des codes) ; canal et page
* d'accueil sont des listes deroulantes. Les NOMS de champs postes sont inchanges.
*
* @var int $roleId * @var int $roleId
* @var bool $isAdminRole * @var bool $isAdminRole
* @var array<int, array<string, mixed>> $permissions catalogue {id, code, label} * @var array<int, array<string, mixed>> $permissions catalogue {id, code, label}
@ -41,11 +45,79 @@ $val = static fn (string $k): string => htmlspecialchars((string) ($vals[$k] ??
$err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : ''; $err = static fn (string $k): string => isset($errs[$k]) && is_string($errs[$k]) ? htmlspecialchars($errs[$k], ENT_QUOTES, 'UTF-8') : '';
$selectedSource = (string) ($vals['order_source'] ?? ''); $selectedSource = (string) ($vals['order_source'] ?? '');
$active = (bool) ($vals['is_active'] ?? true); $active = (bool) ($vals['is_active'] ?? true);
// --- Correspondances humaines (presentation seule) ---
// Canal de commande : enum technique -> libelle parlant.
$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'];
$canalLabel = static fn (string $enum): string => $canalLabels[$enum] ?? $enum;
// Pages proposees comme page d'accueil (liste deroulante) : chemin -> libelle.
$routeOptions = [
'/admin/dashboard' => 'Tableau de bord',
'/admin/stats' => 'Statistiques',
'/admin/products' => 'Produits',
'/admin/menus' => 'Menus',
'/admin/ingredients' => 'Stock',
'/admin/categories' => 'Categories',
'/admin/users' => 'Comptes',
'/admin/roles' => 'Roles',
];
$currentRoute = (string) ($vals['default_route'] ?? '');
// Toujours pouvoir reselectionner la valeur courante meme si hors liste (ex. seed).
if ($currentRoute !== '' && !isset($routeOptions[$currentRoute])) {
$routeOptions[$currentRoute] = $currentRoute;
}
// Permissions : code technique -> [groupe, action]. La base reste la source des codes.
$permMap = [
'product.read' => ['Produits', 'Voir'],
'product.create' => ['Produits', 'Creer'],
'product.update' => ['Produits', 'Modifier'],
'product.delete' => ['Produits', 'Supprimer'],
'menu.read' => ['Menus', 'Voir'],
'menu.create' => ['Menus', 'Creer'],
'menu.update' => ['Menus', 'Modifier'],
'menu.delete' => ['Menus', 'Supprimer'],
'category.manage' => ['Catalogue & recettes', 'Gerer les categories'],
'ingredient.manage' => ['Catalogue & recettes', 'Gerer les ingredients et recettes'],
'stock.read' => ['Stock', 'Voir'],
'stock.count' => ['Stock', "Faire l'inventaire"],
'stock.manage' => ['Stock', 'Reapprovisionner'],
'order.read' => ['Commandes', 'Voir'],
'order.create' => ['Commandes', 'Creer'],
'order.deliver' => ['Commandes', 'Livrer'],
'order.cancel' => ['Commandes', 'Annuler'],
'user.read' => ['Comptes', 'Voir'],
'user.create' => ['Comptes', 'Creer'],
'user.update' => ['Comptes', 'Modifier'],
'user.deactivate' => ['Comptes', 'Desactiver'],
'role.manage' => ['Roles & statistiques', 'Gerer les roles'],
'stats.read' => ['Roles & statistiques', 'Voir les statistiques'],
];
$groupOrder = ['Produits', 'Menus', 'Catalogue & recettes', 'Stock', 'Commandes', 'Comptes', 'Roles & statistiques', 'Autres'];
// Regroupe le catalogue recu par domaine humain.
$grouped = [];
foreach ($perms as $p) {
$code = (string) ($p['code'] ?? '');
$map = $permMap[$code] ?? ['Autres', (string) ($p['label'] ?? $code)];
$grouped[$map[0]][] = [
'id' => (int) ($p['id'] ?? 0),
'action' => $map[1],
'checked' => in_array((int) ($p['id'] ?? 0), $selPerms, true),
];
}
?> ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title"><?= $id !== 0 ? 'Modifier le role' : 'Nouveau role' ?></h1> <h1 class="page-title"><?= $id !== 0 ? 'Modifier le role' : 'Nouveau role' ?></h1>
<?php if ($isAdmin): ?><p class="page-subtitle">Role administrateur : il doit conserver <code>role.manage</code> et rester actif.</p><?php endif; ?> <p class="page-subtitle">
<?php if ($isAdmin): ?>
Role administrateur : il doit garder le droit de gerer les roles et rester actif.
<?php else: ?>
Definissez ce que ce role peut faire dans le back-office.
<?php endif; ?>
</p>
</div> </div>
</div> </div>
@ -53,20 +125,21 @@ $active = (bool) ($vals['is_active'] ?? true);
<input type="hidden" name="_csrf" value="<?= $csrf ?>"> <input type="hidden" name="_csrf" value="<?= $csrf ?>">
<div class="form-group"> <div class="form-group">
<label class="form-label" for="code">Code</label> <label class="form-label" for="label">Nom du role</label>
<?php if ($id === 0): ?> <input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required>
<input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required> <?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p><small class="muted">Le code est immuable apres creation.</small></p>
<?php endif; ?>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="label">Libelle</label> <label class="form-label" for="code">Code interne</label>
<input class="form-input" type="text" id="label" name="label" maxlength="80" value="<?= $val('label') ?>" required> <?php if ($id === 0): ?>
<?php if ($err('label') !== ''): ?><p class="form-error"><?= $err('label') ?></p><?php endif; ?> <input class="form-input" type="text" id="code" name="code" maxlength="40" value="<?= $val('code') ?>" required>
<p class="form-helper">Identifiant technique (sans espace), non modifiable apres creation.</p>
<?php if ($err('code') !== ''): ?><p class="form-error"><?= $err('code') ?></p><?php endif; ?>
<?php else: ?>
<input class="form-input" type="text" id="code" value="<?= $val('code') ?>" disabled>
<p class="form-helper">Identifiant technique, non modifiable apres creation.</p>
<?php endif; ?>
</div> </div>
<div class="form-group"> <div class="form-group">
@ -75,60 +148,69 @@ $active = (bool) ($vals['is_active'] ?? true);
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="default_route">Route par defaut (landing)</label> <label class="form-label" for="default_route">Page d'accueil apres connexion</label>
<input class="form-input" type="text" id="default_route" name="default_route" maxlength="120" value="<?= $val('default_route') ?>"> <select class="form-input" id="default_route" name="default_route">
<option value=""> Aucune </option>
<?php foreach ($routeOptions as $path => $pageLabel): ?>
<option value="<?= htmlspecialchars($path, ENT_QUOTES, 'UTF-8') ?>"<?= $path === $currentRoute ? ' selected' : '' ?>><?= htmlspecialchars($pageLabel, ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?>
</select>
<p class="form-helper">L'ecran affiche a cette personne quand elle se connecte.</p>
<?php if ($err('default_route') !== ''): ?><p class="form-error"><?= $err('default_route') ?></p><?php endif; ?> <?php if ($err('default_route') !== ''): ?><p class="form-error"><?= $err('default_route') ?></p><?php endif; ?>
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="order_source">Source de commande auto-taggee</label> <label class="form-label" for="order_source">Canal de commande</label>
<select class="form-input" id="order_source" name="order_source"> <select class="form-input" id="order_source" name="order_source">
<option value="">-- aucune (admin/manager) --</option> <option value=""> Aucun (role de gestion) </option>
<?php foreach ($srcList as $src): ?> <?php foreach ($srcList as $src): ?>
<option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?></option> <option value="<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>"<?= $src === $selectedSource ? ' selected' : '' ?>><?= htmlspecialchars($canalLabel($src), ENT_QUOTES, 'UTF-8') ?></option>
<?php endforeach; ?> <?php endforeach; ?>
</select> </select>
<p class="form-helper">Les commandes prises par ce role sont rattachees a ce canal.</p>
<?php if ($err('order_source') !== ''): ?><p class="form-error"><?= $err('order_source') ?></p><?php endif; ?> <?php if ($err('order_source') !== ''): ?><p class="form-error"><?= $err('order_source') ?></p><?php endif; ?>
</div> </div>
<?php if ($id !== 0): ?> <?php if ($id !== 0): ?>
<div class="form-group"> <div class="form-group">
<label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Role actif</label> <label class="form-label"><input type="checkbox" name="is_active" value="1"<?= $active ? ' checked' : '' ?>> Ce role est actif</label>
</div> </div>
<?php endif; ?> <?php endif; ?>
<fieldset class="form-group"> <fieldset class="form-group">
<legend>Permissions</legend> <legend>Droits d'acces</legend>
<p class="form-helper">Cochez ce que ce role est autorise a faire.</p>
<?php if ($err('permissions') !== ''): ?><p class="form-error"><?= $err('permissions') ?></p><?php endif; ?> <?php if ($err('permissions') !== ''): ?><p class="form-error"><?= $err('permissions') ?></p><?php endif; ?>
<div style="max-height:320px; overflow-y:auto;"> <div class="perm-grid">
<?php foreach ($perms as $p): ?> <?php foreach ($groupOrder as $group): ?>
<?php <?php if (empty($grouped[$group])): continue; endif; ?>
$pid = (int) ($p['id'] ?? 0); <div class="perm-group">
$checked = in_array($pid, $selPerms, true); <h4 class="perm-group-title"><?= htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?></h4>
?> <?php foreach ($grouped[$group] as $item): ?>
<label style="display:block; padding:2px 0;"> <label class="perm-opt">
<input type="checkbox" name="perm_<?= $pid ?>" value="1"<?= $checked ? ' checked' : '' ?>> <input type="checkbox" name="perm_<?= $item['id'] ?>" value="1"<?= $item['checked'] ? ' checked' : '' ?>>
<code><?= htmlspecialchars((string) ($p['code'] ?? ''), ENT_QUOTES, 'UTF-8') ?></code> <?= htmlspecialchars($item['action'], ENT_QUOTES, 'UTF-8') ?>
<span class="muted">- <?= htmlspecialchars((string) ($p['label'] ?? ''), ENT_QUOTES, 'UTF-8') ?></span> </label>
</label> <?php endforeach; ?>
</div>
<?php endforeach; ?> <?php endforeach; ?>
</div> </div>
</fieldset> </fieldset>
<fieldset class="form-group"> <fieldset class="form-group">
<legend>Sources de tableau de bord visibles</legend> <legend>Canaux visibles sur le tableau de bord</legend>
<?php foreach ($srcList as $src): ?> <?php foreach ($srcList as $src): ?>
<label style="display:inline-block; margin-right:1rem;"> <label class="perm-opt">
<input type="checkbox" name="source_<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>" value="1"<?= in_array($src, $selSources, true) ? ' checked' : '' ?>> <input type="checkbox" name="source_<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?>" value="1"<?= in_array($src, $selSources, true) ? ' checked' : '' ?>>
<?= htmlspecialchars($src, ENT_QUOTES, 'UTF-8') ?> <?= htmlspecialchars($canalLabel($src), ENT_QUOTES, 'UTF-8') ?>
</label> </label>
<?php endforeach; ?> <?php endforeach; ?>
</fieldset> </fieldset>
<fieldset class="form-group"> <fieldset class="form-group">
<legend>Re-autorisation (PIN equipier)</legend> <legend>Confirmation par PIN</legend>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="pin_email">Email equipier</label> <label class="form-label" for="pin_email">Email de l'equipier</label>
<input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off"> <input class="form-input" type="email" id="pin_email" name="pin_email" autocomplete="off">
</div> </div>
<div class="form-group"> <div class="form-group">

View file

@ -4,6 +4,8 @@ declare(strict_types=1);
/** /**
* Liste des roles (RBAC, role.manage), injectee dans admin/layout.php. Texte echappe. * Liste des roles (RBAC, role.manage), injectee dans admin/layout.php. Texte echappe.
* Presentation humanisee : page d'accueil et canal affiches en clair (la base garde
* les chemins / enums techniques).
* *
* @var array<int, array<string, mixed>> $roles * @var array<int, array<string, mixed>> $roles
*/ */
@ -11,11 +13,25 @@ declare(strict_types=1);
/** @var array<int, array<string, mixed>> $rows */ /** @var array<int, array<string, mixed>> $rows */
$rows = isset($roles) && is_array($roles) ? $roles : []; $rows = isset($roles) && is_array($roles) ? $roles : [];
$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');
$routeLabels = [
'/admin/dashboard' => 'Tableau de bord',
'/admin/stats' => 'Statistiques',
'/admin/products' => 'Produits',
'/admin/menus' => 'Menus',
'/admin/ingredients' => 'Stock',
'/admin/categories' => 'Categories',
'/admin/users' => 'Comptes',
'/admin/roles' => 'Roles',
];
$canalLabels = ['kiosk' => 'Borne', 'counter' => 'Comptoir', 'drive' => 'Drive'];
$routeHuman = static fn (string $r): string => $r === '' ? '—' : ($routeLabels[$r] ?? $r);
$canalHuman = static fn (?string $s): string => ($s === null || $s === '') ? '—' : ($canalLabels[$s] ?? $s);
?> ?>
<div class="page-header"> <div class="page-header">
<div> <div>
<h1 class="page-title">Roles et permissions</h1> <h1 class="page-title">Roles et droits d'acces</h1>
<p class="page-subtitle">Matrice RBAC. Modifier un role est une action sensible (PIN + audit).</p> <p class="page-subtitle">Modifier un role est une action sensible (confirmation par PIN).</p>
</div> </div>
<div class="page-actions"> <div class="page-actions">
<a class="btn btn-primary" href="/admin/roles/new">Nouveau role</a> <a class="btn btn-primary" href="/admin/roles/new">Nouveau role</a>
@ -27,10 +43,10 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Code</th> <th>Nom</th>
<th>Libelle</th> <th>Code interne</th>
<th>Route par defaut</th> <th>Page d'accueil</th>
<th>Source</th> <th>Canal</th>
<th>Statut</th> <th>Statut</th>
<th style="width:120px;"></th> <th style="width:120px;"></th>
</tr> </tr>
@ -43,12 +59,13 @@ $esc = static fn (mixed $v): string => htmlspecialchars((string) $v, ENT_QUOTES,
<?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;
$src = isset($row['order_source']) && is_string($row['order_source']) ? $row['order_source'] : null;
?> ?>
<tr> <tr>
<td class="fw-600"><?= $esc($row['code'] ?? '') ?></td> <td class="fw-600"><?= $esc($row['label'] ?? '') ?></td>
<td><?= $esc($row['label'] ?? '') ?></td> <td class="muted"><?= $esc($row['code'] ?? '') ?></td>
<td class="muted"><?= $esc($row['default_route'] ?? '') ?></td> <td class="muted"><?= $esc($routeHuman((string) ($row['default_route'] ?? ''))) ?></td>
<td class="muted"><?= $esc($row['order_source'] ?? '-') ?></td> <td class="muted"><?= $esc($canalHuman($src)) ?></td>
<td> <td>
<?php if ($active): ?> <?php if ($active): ?>
<span class="pill pill-success">Actif</span> <span class="pill pill-success">Actif</span>

View file

@ -1386,3 +1386,48 @@ tbody td.mono {
.pin-modal-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; } .pin-modal-title { font-size: 18px; font-weight: 800; letter-spacing: -0.3px; }
.pin-modal-sub { font-size: 13px; color: var(--color-text-muted); margin-top: 3px; } .pin-modal-sub { font-size: 13px; color: var(--color-text-muted); margin-top: 3px; }
.pin-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; } .pin-modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 8px; }
/* --- Matrice de droits d'acces groupee (formulaire Roles humanise) --- */
.perm-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 14px;
margin-top: 10px;
}
.perm-group {
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 14px 16px;
background: var(--color-surface);
}
.perm-group-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 8px;
color: var(--color-text);
}
.perm-opt {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
color: var(--color-text-sec);
margin: 3px 14px 3px 0;
}
@media (max-width: 700px) {
.perm-grid { grid-template-columns: 1fr; }
}
/* Fieldsets de formulaire : pas de bordure native ; la legende = titre de section. */
.form-card fieldset {
border: none;
padding: 0;
margin: 0;
}
.form-card legend {
font-size: 15px;
font-weight: 700;
color: var(--color-text);
padding: 0;
margin-bottom: 4px;
}