feat(back-office): refonte saisie commande comptoir/drive (prix, verrou, nav, file) (#100)
All checks were successful
CI / secret-scan (push) Successful in 15s
CI / php-lint (push) Successful in 35s
CI / static-tests (push) Successful in 1m0s
CI / js-tests (push) Successful in 32s

This commit is contained in:
Corentin JOGUET 2026-06-24 12:05:25 +02:00
parent 0968a98668
commit 352355f5a5
12 changed files with 1027 additions and 91 deletions

View file

@ -18,12 +18,16 @@ final class UserDirectory
}
/**
* @return array{name: string, role_label: string, email: string}
* order_source : canal de saisie du role ('counter' | 'drive' | '' pour les
* roles globaux admin/manager/kitchen). Sert au layout a router le lien
* "Saisie commande" vers la landing du bon canal sans une requete dediee.
*
* @return array{name: string, role_label: string, email: string, order_source: string}
*/
public function displayInfo(int $userId): array
{
$row = $this->db->fetch(
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label '
'SELECT u.first_name, u.last_name, u.email, r.label AS role_label, r.order_source '
. 'FROM user u JOIN role r ON r.id = u.role_id WHERE u.id = :id',
['id' => $userId],
);
@ -36,6 +40,7 @@ final class UserDirectory
'name' => $name !== '' ? $name : 'Utilisateur',
'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '',
'email' => is_string($row['email'] ?? null) ? $row['email'] : '',
'order_source' => is_string($row['order_source'] ?? null) ? $row['order_source'] : '',
];
}
}

View file

@ -84,11 +84,11 @@ final class ProductRepository
// un libelle d'affichage seulement.
return $this->db->fetchAll(
'SELECT p.id, p.category_id, p.name, p.description, p.price_cents, p.size_cl, '
. 'p.image_path, p.display_order, mv.name AS maxi_variant_name '
. 'p.image_path, p.display_order, c.name AS category_name, mv.name AS maxi_variant_name '
. 'FROM product p JOIN category c ON c.id = p.category_id '
. 'LEFT JOIN product mv ON mv.id = p.maxi_variant_product_id '
. 'WHERE p.is_available = 1 AND c.is_active = 1 AND p.base_product_id IS NULL '
. 'ORDER BY p.display_order, p.name',
. 'ORDER BY c.display_order, c.name, p.display_order, p.name',
);
}

View file

@ -68,6 +68,11 @@ abstract class AdminController extends AuthenticatedController
'permissions' => $this->authorizer()->permissionsFor($roleId),
'csrfToken' => Csrf::token($this->sessionManager()),
'activeNav' => '',
// Canal de saisie du role courant ('counter' | 'drive') pour que le lien
// "Saisie commande" du layout envoie un equipier drive vers /drive/orders
// et un equipier comptoir vers /counter/orders. Derive de role.order_source
// (remonte par displayInfo, qui joint deja la table role).
'orderChannel' => $info['order_source'] === 'drive' ? 'drive' : 'counter',
'flash' => $this->takeFlash(),
];

View file

@ -56,18 +56,25 @@ class CounterOrderController extends AdminController
}
$source = $this->source();
$orderQuery = $this->orderQuery();
// RG-1 (5.1, source filter) : ne lister que les commandes du canal. recent()
// ramene les plus recentes tous canaux ; on filtre sur la source derivee du
// chemin pour que le comptoir ne voie pas le drive et inversement.
$orders = array_values(array_filter(
$this->orderQuery()->recent(50),
$orderQuery->recent(50),
static fn (array $o): bool => (string) ($o['source'] ?? '') === $source,
));
// File "En cours" (RG-T12) : commandes du canal au statut paid non livrees,
// la plus ancienne d'abord (tri paid_at croissant fait par paidQueue). Filtree
// a la SEULE source du canal pour que l'equipier ne voie que ce qu'il sert.
$inProgress = $orderQuery->paidQueue([$source]);
return $this->channelView('admin/counter/index', $source, [
'title' => $this->channelTitle($source) . ' - Wakdo Admin',
'orders' => $orders,
'inProgress' => $inProgress,
], $guard);
}
@ -115,6 +122,11 @@ class CounterOrderController extends AdminController
$source = $this->source();
$serviceMode = (string) ($form['service_mode'] ?? '');
// Numero de table (confort comptoir) : ne porte de sens qu'en sur place. On ne
// le transmet qu'en dine_in ; persist() le rejette de toute facon hors dine_in,
// mais ne pas le passer evite un INVALID_SERVICE_TAG sur une saisie residuelle.
$serviceTag = $serviceMode === 'dine_in' ? trim((string) ($form['service_tag'] ?? '')) : '';
// Chemin unifie : le panier construit par counter-order.js arrive serialise
// dans items_json. Quand il est present, il fait foi ; les quantites legacy
// qty_<id> ne servent qu'au repli sans JS (degradation gracieuse).
@ -127,9 +139,14 @@ class CounterOrderController extends AdminController
return $this->renderForm($guard, $source, $form, 'Ajoutez au moins un produit ou un menu.', 422);
}
$req = ['service_mode' => $serviceMode, 'items' => $items];
if ($serviceTag !== '') {
$req['service_tag'] = $serviceTag;
}
try {
$order = $this->orders()->createStaffOrder(
['service_mode' => $serviceMode, 'items' => $items],
$req,
$guard->userId ?? 0,
$source,
);
@ -360,6 +377,7 @@ class CounterOrderController extends AdminController
'products' => $products,
'menus' => $this->menusWithSlots($productRepository),
'serviceMode' => (string) ($values['service_mode'] ?? ($source === 'drive' ? 'drive' : 'dine_in')),
'serviceTag' => (string) ($values['service_tag'] ?? ''),
'error' => $error,
], $guard, $status);
}

View file

@ -4,11 +4,15 @@ declare(strict_types=1);
/**
* Liste des commandes du canal (comptoir ou drive), injectee dans admin/layout.php.
* Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle commande".
* Partagee par les deux canaux ; le titre, le lien de creation et la source viennent
* du controleur (CounterOrderController::channelView). Toute valeur est echappee (RG-T15).
* Deux sections : "En cours" (commandes payees non livrees du canal, la plus ancienne
* d'abord, RG-T12) EN HAUT pour le service, puis l'historique recent (tous statuts)
* en dessous. Lecture seule : numero, mode, statut, total, date + bouton "Nouvelle
* commande". Partagee par les deux canaux ; le titre, le lien de creation et la source
* viennent du controleur (CounterOrderController::channelView). Echappement RG-T15.
* Aucun rafraichissement auto (polling hors scope) : la page se relit a la navigation.
*
* @var list<array<string, mixed>> $orders
* @var list<array<string, mixed>> $orders historique recent (tous statuts)
* @var list<array<string, mixed>> $inProgress file "En cours" (paid non livre, canal)
* @var string $channelTitle
* @var string $newPath
*/
@ -39,6 +43,8 @@ $statusPill = static fn (string $s): string => match ($s) {
/** @var list<array<string, mixed>> $rows */
$rows = isset($orders) && is_array($orders) ? $orders : [];
/** @var list<array<string, mixed>> $queue */
$queue = isset($inProgress) && is_array($inProgress) ? $inProgress : [];
$heading = isset($channelTitle) && is_string($channelTitle) ? $channelTitle : 'Commandes';
$createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orders/new';
?>
@ -48,8 +54,45 @@ $createPath = isset($newPath) && is_string($newPath) ? $newPath : '/counter/orde
<h1 id="counter-heading" class="admin-section__title"><?= $esc($heading) ?></h1>
<a class="btn btn-primary" href="<?= $esc($createPath) ?>">Nouvelle commande</a>
</div>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<h2 class="admin-section__subtitle">En cours</h2>
<p class="admin-section__sub"><?= count($queue) ?> commande(s) a servir</p>
<?php if ($queue === []): ?>
<p class="admin-empty">Aucune commande en cours.</p>
<?php else: ?>
<table class="admin-table">
<thead>
<tr>
<th>Numero</th>
<th>Mode</th>
<th>Table</th>
<th>Total</th>
<th>Payee a</th>
</tr>
</thead>
<tbody>
<?php foreach ($queue as $o): ?>
<?php
// Numero de table : pertinent seulement en sur place (service_tag est
// NULL hors dine_in cote serveur). On affiche un tiret sinon, pour que
// l'equipier distingue "pas de table" d'une donnee manquante.
$queueMode = (string) ($o['service_mode'] ?? '');
$queueTag = $queueMode === 'dine_in' ? (string) ($o['service_tag'] ?? '') : '';
?>
<tr>
<td><strong><?= $esc($o['order_number'] ?? '') ?></strong></td>
<td><?= $esc($modeLabel($queueMode)) ?></td>
<td><?= $queueTag !== '' ? $esc($queueTag) : '-' ?></td>
<td><?= $esc($euros($o['total_ttc_cents'] ?? 0)) ?></td>
<td><?= $esc($o['paid_at'] ?? '') ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
<h2 class="admin-section__subtitle">Historique recent</h2>
<p class="admin-section__sub"><?= count($rows) ?> commande(s) recente(s)</p>
<?php if ($rows === []): ?>
<p class="admin-empty">Aucune commande pour ce canal.</p>
<?php else: ?>

View file

@ -3,8 +3,8 @@
declare(strict_types=1);
/**
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c), injecte dans
* admin/layout.php. Produits commandables ET menus composes (slots
* Composeur de commande comptoir/drive COMPLET (sous-lot 3c + refonte saisie),
* injecte dans admin/layout.php. Produits commandables ET menus composes (slots
* accompagnement/boisson/sauce + format Normal/Maxi + modificateurs d'ingredients).
*
* Le panier est construit cote client par counter-order.js (CSP 'self', vanilla JS,
@ -12,16 +12,24 @@ declare(strict_types=1);
* #counter-order-form (dont la composition PROPOSABLE de chaque produit et du burger
* de chaque menu : ingredients retirables / ajoutables + surcout), et serialise les
* items en JSON dans le champ cache #items_json a la soumission. Le serveur revalide
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Le tableau de
* quantites produit `qty_<id>` reste present comme repli sans JS (3a).
* tout (RG-T18, resolveModifiers) et recalcule les prix (RG-T16). Les prix affiches
* cote client (par ligne + total + libelle du bouton) sont INDICATIFS : le serveur
* reste seul juge. Le tableau de quantites produit `qty_<id>` reste present comme
* repli sans JS (3a) : le champ quantite est rendu EDITABLE pour TOUS les produits.
* Pour un produit personnalisable, c'est counter-order.js qui neutralise le champ au
* cablage (desactivation + indice "via Personnaliser") et route la saisie vers la
* modale ; sans JS, le champ qty de base fonctionne (commande sans modificateurs, ce
* que legacyQuantities sait traiter). La gestion des modificateurs depend donc de JS.
*
* Partage par les deux canaux ; la source/landing viennent du controleur. Au canal
* drive, service_mode est verrouille a 'drive' (RG-T09). Echappement RG-T15.
* drive, service_mode est FIGE a 'drive' (affichage non editable + input cache,
* RG-T09 : un select readonly reste editable, on ne s'y fie pas). Echappement RG-T15.
*
* @var list<array<string, mixed>> $products
* @var list<array<string, mixed>> $menus menus + slots (option_product_ids)
* @var string $source 'counter' | 'drive'
* @var string $serviceMode valeur preselectionnee / reaffichee
* @var string $serviceTag numero de table reaffiche (re-rendu d'erreur)
* @var string $landing retour a la liste du canal
* @var string|null $error
* @var string $csrfToken
@ -43,6 +51,7 @@ $chan = isset($source) && $source === 'drive' ? 'drive' : 'counter';
$action = $chan === 'drive' ? '/drive/orders' : '/counter/orders';
$backTo = isset($landing) && is_string($landing) ? $landing : '/counter/orders';
$mode = isset($serviceMode) && is_string($serviceMode) ? $serviceMode : ($chan === 'drive' ? 'drive' : 'dine_in');
$tag = isset($serviceTag) && is_string($serviceTag) ? $serviceTag : '';
$errorMessage = isset($error) && is_string($error) ? $error : null;
/** @var list<array<string, mixed>> $productRows */
@ -102,10 +111,20 @@ $jsMenus = array_map(
$menuRows,
);
// RG-T09 : au drive, le seul mode possible est 'drive'. Le comptoir choisit librement.
$modeOptions = $chan === 'drive'
? ['drive' => 'Drive']
: ['dine_in' => 'Sur place', 'takeaway' => 'A emporter'];
// Regroupement des produits par categorie (7b) : sous-titre par categorie pour que
// l'equipier scanne plus vite. availableForCatalogue trie deja par categorie puis
// display_order ; on conserve cet ordre et on agrege les lignes consecutives de meme
// categorie. category_name absent -> groupe "Autres" (evite une cle vide a l'ecran).
$productGroups = [];
foreach ($productRows as $p) {
$catName = isset($p['category_name']) && is_string($p['category_name']) && $p['category_name'] !== ''
? $p['category_name']
: 'Autres';
if (!isset($productGroups[$catName])) {
$productGroups[$catName] = [];
}
$productGroups[$catName][] = $p;
}
?>
<div class="page-header">
<h1 class="page-title">Nouvelle commande <?= $chan === 'drive' ? 'drive' : 'comptoir' ?></h1>
@ -123,18 +142,38 @@ $modeOptions = $chan === 'drive'
<div class="form-group">
<label class="form-label" for="service_mode">Mode de service</label>
<select class="form-input" id="service_mode" name="service_mode"<?= $chan === 'drive' ? ' readonly' : '' ?>>
<?php foreach ($modeOptions as $value => $label): ?>
<option value="<?= $esc($value) ?>"<?= $mode === $value ? ' selected' : '' ?>><?= $esc($label) ?></option>
<?php endforeach; ?>
<?php if ($chan === 'drive'): ?>
<?php /* RG-T09 : au drive, le mode est impose. On AFFICHE 'Drive' fige et on
transmet la valeur par un champ cache (un select readonly resterait
editable, donc non fiable ; disabled ne serait pas soumis). */ ?>
<p class="form-static" id="service_mode_display">Drive</p>
<input type="hidden" name="service_mode" id="service_mode" value="drive">
<?php else: ?>
<select class="form-input" id="service_mode" name="service_mode">
<option value="dine_in"<?= $mode === 'dine_in' ? ' selected' : '' ?>>Sur place</option>
<option value="takeaway"<?= $mode === 'takeaway' ? ' selected' : '' ?>>A emporter</option>
</select>
<?php endif; ?>
</div>
<?php if ($chan !== 'drive'): ?>
<?php /* 7a : numero de table, utile uniquement en sur place. Masque par defaut
(toggle JS sur le mode) ; le champ reste soumis tel quel, persist()
l'ignore hors dine_in. */ ?>
<div class="form-group" id="service_tag_group"<?= $mode === 'dine_in' ? '' : ' hidden' ?>>
<label class="form-label" for="service_tag">Numero de table</label>
<input class="form-input" type="text" id="service_tag" name="service_tag"
maxlength="20" value="<?= $esc($tag) ?>" autocomplete="off">
</div>
<?php endif; ?>
<fieldset class="form-group">
<legend>Produits</legend>
<?php if ($productRows === []): ?>
<p class="admin-empty">Aucun produit commandable pour le moment.</p>
<?php else: ?>
<?php foreach ($productGroups as $catName => $catProducts): ?>
<h3 class="order-group__title"><?= $esc($catName) ?></h3>
<table class="admin-table">
<thead>
<tr>
@ -145,7 +184,7 @@ $modeOptions = $chan === 'drive'
</tr>
</thead>
<tbody>
<?php foreach ($productRows as $p): ?>
<?php foreach ($catProducts as $p): ?>
<?php
$pid = (int) ($p['id'] ?? 0);
// Un produit ne porte un bouton "Personnaliser" que si sa recette
@ -156,10 +195,20 @@ $modeOptions = $chan === 'drive'
<td><?= $esc($p['name'] ?? '') ?></td>
<td><?= $esc($euros($p['price_cents'] ?? 0)) ?></td>
<td>
<?php /* 4 (progressive enhancement) : le champ quantite est
rendu EDITABLE pour TOUS les produits, pour que la saisie
marche SANS JS (qty de base, sans modificateurs). C'est
counter-order.js qui neutralise ce champ au cablage pour un
produit personnalisable et route la saisie vers la modale.
L'indice "via Personnaliser" est cache par defaut et revele
par le JS, pour ne pas perturber le rendu sans JS. */ ?>
<input class="form-input order-qty" type="number" min="0" value="0"
id="qty_<?= $pid ?>" name="qty_<?= $pid ?>"
data-product-id="<?= $pid ?>"
aria-label="Quantite <?= $esc($p['name'] ?? '') ?>">
<?php if ($hasModifiers): ?>
<span class="order-qty-hint" data-qty-hint="<?= $pid ?>" hidden>via Personnaliser</span>
<?php endif; ?>
</td>
<td>
<?php if ($hasModifiers): ?>
@ -172,6 +221,7 @@ $modeOptions = $chan === 'drive'
<?php endforeach; ?>
</tbody>
</table>
<?php endforeach; ?>
<?php endif; ?>
</fieldset>
@ -182,10 +232,18 @@ $modeOptions = $chan === 'drive'
<?php else: ?>
<ul class="menu-list" id="menu-list">
<?php foreach ($menuRows as $m): ?>
<?php $mid = (int) ($m['id'] ?? 0); ?>
<?php
$mid = (int) ($m['id'] ?? 0);
$priceNormal = (int) ($m['price_normal_cents'] ?? 0);
$priceMaxi = (int) ($m['price_maxi_cents'] ?? 0);
?>
<li class="menu-list__item">
<span class="menu-list__name"><?= $esc($m['name'] ?? '') ?></span>
<span class="menu-list__price"><?= $esc($euros($m['price_normal_cents'] ?? 0)) ?></span>
<?php /* 6 : afficher les deux prix qualifies (Normal / Maxi) pour que
le choix de format soit lisible avant d'ouvrir la modale. */ ?>
<span class="menu-list__price">
Normal <?= $esc($euros($priceNormal)) ?> / Maxi <?= $esc($euros($priceMaxi)) ?>
</span>
<button class="btn btn-secondary menu-configure" type="button" data-menu-id="<?= $mid ?>">
Configurer
</button>
@ -200,10 +258,12 @@ $modeOptions = $chan === 'drive'
<ul class="order-cart" id="order-cart" aria-live="polite">
<li class="order-cart__empty" id="order-cart-empty">Panier vide.</li>
</ul>
<?php /* 1 : total indicatif du panier (recalcule cote serveur a l'encaissement). */ ?>
<p class="order-total" id="order-total">Total : <span id="order-total-value"><?= $esc($euros(0)) ?></span></p>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit">Encaisser la commande</button>
<button class="btn btn-primary" type="submit" id="order-submit">Encaisser <?= $esc($euros(0)) ?></button>
<a class="btn btn-secondary" href="<?= $esc($backTo) ?>">Annuler</a>
</div>
</form>

View file

@ -18,6 +18,7 @@ declare(strict_types=1);
* @var list<string> $permissions
* @var string $csrfToken
* @var string $activeNav
* @var string $orderChannel 'counter' | 'drive' : canal de saisie du role courant
* @var string|null $flash
*/
@ -27,6 +28,13 @@ $userRole = htmlspecialchars($currentUserRole ?? '', ENT_QUOTES, 'UTF-8');
$csrf = htmlspecialchars($csrfToken ?? '', ENT_QUOTES, 'UTF-8');
$active = is_string($activeNav ?? null) ? $activeNav : '';
// Canal de saisie du role courant : un equipier drive est route vers /drive/orders,
// les autres roles vers /counter/orders. CounterOrderController pose activeNav a
// 'counter' ou 'drive' selon le chemin ; on marque le lien actif sur l'un OU l'autre.
$channel = ($orderChannel ?? '') === 'drive' ? 'drive' : 'counter';
$orderHref = $channel === 'drive' ? '/drive/orders' : '/counter/orders';
$orderActive = ($active === 'counter' || $active === 'drive') ? 'sidebar-item active' : 'sidebar-item';
/** @var list<string> $perms */
$perms = isset($permissions) && is_array($permissions) ? $permissions : [];
$can = static fn (string $code): bool => in_array($code, $perms, true);
@ -119,9 +127,9 @@ $navClass = static function (string $code, string $current): string {
<div class="sidebar-section">
<div class="sidebar-section-label">Pilotage</div>
<?php if ($can('order.create')): ?>
<?php /* Lien generique vers le comptoir ; le canal effectif (counter/drive)
est derive du chemin par CounterOrderController (mlt 4.1). */ ?>
<a href="/counter/orders" class="<?= $navClass('counter', $active) ?>">Saisie commande</a>
<?php /* Le canal (counter/drive) vient de role.order_source : un equipier
drive est route vers /drive/orders, les autres vers /counter/orders. */ ?>
<a href="<?= htmlspecialchars($orderHref, ENT_QUOTES, 'UTF-8') ?>" class="<?= $orderActive ?>">Saisie commande</a>
<?php endif; ?>
<?php if ($can('stats.read')): ?>
<a href="/admin/stats" class="<?= $navClass('stats', $active) ?>">Statistiques</a>

View file

@ -1431,3 +1431,123 @@ tbody td.mono {
padding: 0;
margin-bottom: 4px;
}
/* =============================================================================
Saisie de commande comptoir/drive (counter-order.js + admin/counter/new.php)
Composeur : groupes produits, liste menus, panier chiffre, total, modale.
============================================================================= */
/* Mode de service fige (drive) : affichage non editable a la place du select. */
.form-static {
font-weight: 700;
color: var(--color-text);
padding: 8px 0;
margin: 0;
}
/* Sous-titre de groupe de produits (regroupement par categorie). */
.order-group__title {
font-size: 14px;
font-weight: 700;
color: var(--color-yellow-ink);
margin: 14px 0 6px;
}
/* Champ quantite desactive pour un produit personnalisable (saisie en modale). */
.order-qty--disabled {
background: var(--color-surface);
color: var(--color-text-muted);
cursor: not-allowed;
}
/* Indice "via Personnaliser" revele par JS a cote du champ qty neutralise. */
.order-qty-hint {
display: inline-block;
margin-left: 8px;
font-size: 13px;
color: var(--color-text-muted);
font-style: italic;
}
/* Deux prix d'un menu (Normal / Maxi) dans la liste. */
.menu-list { list-style: none; padding: 0; margin: 0; }
.menu-list__item {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.menu-list__name { font-weight: 600; flex: 1; }
.menu-list__price { color: var(--color-text-sec); font-variant-numeric: tabular-nums; }
/* Panier : une ligne = libelle + prix + bouton retirer. */
.order-cart { list-style: none; padding: 0; margin: 0; }
.order-cart__empty { color: var(--color-text-muted); padding: 8px 0; }
.order-cart__line {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border-bottom: 1px solid var(--color-border);
}
.order-cart__label { flex: 1; }
.order-cart__price { font-weight: 700; font-variant-numeric: tabular-nums; }
/* Total indicatif du panier. */
.order-total {
text-align: right;
font-size: 16px;
font-weight: 800;
margin: 12px 0 0;
}
/* Modale de composition : overlay + panneau centre. */
.menu-composer__overlay {
position: fixed;
inset: 0;
background: rgba(26, 26, 26, 0.45);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 24px;
}
.menu-composer {
background: var(--color-white);
border-radius: var(--radius-card);
border-top: 3px solid var(--color-yellow);
box-shadow: var(--shadow-card-hover);
width: 100%;
max-width: 460px;
max-height: 90vh;
overflow-y: auto;
padding: 24px;
}
.menu-composer__title { font-size: 18px; font-weight: 800; margin-bottom: 14px; }
.menu-composer__legend { font-weight: 600; margin: 12px 0 4px; }
.menu-composer__slot, .menu-composer__format, .menu-composer__modifiers { margin-bottom: 10px; }
.menu-composer__radio, .menu-composer__modifier {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 3px 14px 3px 0;
}
.menu-composer__error {
color: var(--color-danger-text);
background: var(--color-danger-bg);
border-radius: var(--radius-md);
padding: 8px 10px;
margin: 10px 0;
font-size: 14px;
}
/* Le <p role=alert> reste dans le DOM en permanence (annonce lecteur d'ecran) ;
tant qu'il est vide, on l'efface visuellement pour ne pas afficher un bloc vide. */
.menu-composer__error:empty {
display: none;
}
.menu-composer__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 16px;
}

View file

@ -41,10 +41,51 @@
}
}
// Montant en euros formate comme le PHP number_format(.../100, 2, ',', ' ') des
// vues : virgule decimale ET espace separateur de milliers. Aligne l'affichage
// client sur le rendu serveur (ex. 1 234,50 EUR) pour eviter une divergence visible
// sur les montants >= 1000. Indicatif : le serveur recalcule tout (RG-T16).
function moneyParts(cents) {
var fixed = (Number(cents) / 100).toFixed(2);
var dot = fixed.indexOf('.');
var intPart = fixed.slice(0, dot);
var decPart = fixed.slice(dot + 1);
var sign = '';
if (intPart.charAt(0) === '-') {
sign = '-';
intPart = intPart.slice(1);
}
// Insere un espace tous les 3 chiffres depuis la droite (separateur de milliers).
intPart = intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
return sign + intPart + ',' + decPart;
}
// Surcout d'un ajout, formate en euros (affichage local indicatif ; le serveur
// refige extra_price_cents, RG-T16).
function formatExtra(cents) {
return '+' + (Number(cents) / 100).toFixed(2).replace('.', ',') + ' EUR';
return '+' + moneyParts(cents) + ' EUR';
}
// Montant en euros (sans signe), pour les prix de ligne et le total. Meme format
// que les vues PHP (cf. moneyParts).
function formatEuros(cents) {
return moneyParts(cents) + ' EUR';
}
// Somme des surcouts d'ajout (action 'add') d'une liste de modificateurs choisis,
// resolus via la liste proposable (extra_price_cents). Les retraits ne changent pas
// le prix indicatif. Pur ; le serveur reste seul juge du surcout reel.
function modifiersExtra(proposable, chosen) {
if (!chosen || !chosen.length) {
return 0;
}
var extraById = {};
(proposable || []).forEach(function (m) {
extraById[Number(m.ingredient_id)] = Number(m.extra_price_cents) || 0;
});
return chosen.reduce(function (sum, c) {
return c.action === 'add' ? sum + (extraById[Number(c.ingredient_id)] || 0) : sum;
}, 0);
}
// Etapes composables d'un menu : burger impose ignore (non choisi ici), un pas par
@ -82,6 +123,15 @@
return;
}
// Elements de prix (1) : valeur du total + libelle du bouton d'encaissement.
// Optionnels (le rendu degrade sans eux) -> garde-fous au moment d'ecrire.
var totalValue = doc.getElementById('order-total-value');
var submitBtn = doc.getElementById('order-submit');
// 7a : champ numero de table, visible seulement en sur place (toggle au mode).
var serviceMode = doc.getElementById('service_mode');
var serviceTagGroup = doc.getElementById('service_tag_group');
var products = parseData(form, 'products', '[]'); // [{id, name, price, modifiers:[...]}]
var menus = parseData(form, 'menus', '[]'); // [{id, name, price_normal, price_maxi, burger_modifiers:[...], slots:[...]}]
@ -100,10 +150,24 @@
// Produits routes par la modale (ils portent un bouton "Personnaliser") : leur
// quantite directe qty_<id> est ignoree a la serialisation pour eviter le double
// comptage (le champ reste present pour le repli sans JS).
// comptage. Progressive enhancement (4) : le champ qty est EDITABLE dans le HTML
// (repli sans JS) ; ici, en presence de JS, on le neutralise et on revele
// l'indice "via Personnaliser" pour que l'equipier sache ou saisir la quantite.
var configurableIds = {};
Array.prototype.forEach.call(doc.querySelectorAll('.product-configure'), function (btn) {
configurableIds[Number(btn.dataset.productId)] = true;
var pid = Number(btn.dataset.productId);
configurableIds[pid] = true;
var qtyInput = doc.getElementById('qty_' + pid);
if (qtyInput) {
qtyInput.disabled = true;
qtyInput.classList.add('order-qty--disabled');
qtyInput.setAttribute('aria-label', (qtyInput.getAttribute('aria-label') || 'Quantite') + ' (via Personnaliser)');
}
var hint = doc.querySelector('[data-qty-hint="' + pid + '"]');
if (hint) {
hint.hidden = false;
}
});
function el(tag, className) {
@ -253,6 +317,56 @@
hidden.value = JSON.stringify(items);
}
/* ----------------------------------------------------------------- */
/* Prix indicatifs (1, 6) : par ligne + total + libelle du bouton */
/* ----------------------------------------------------------------- */
// Prix d'une ligne PRODUIT (configuree par la modale) : prix de base + surcout
// des ajouts, le tout multiplie par la quantite. Indicatif (RG-T16 serveur).
function productLineTotal(line) {
var base = (productById[Number(line.productId)] || {}).price || 0;
var extra = modifiersExtra(line.proposable, line.modifiers);
return (Number(base) + extra) * Number(line.quantity || 1);
}
// Prix d'une ligne MENU : price_maxi si format maxi sinon price_normal, plus le
// surcout des ajouts sur le burger. Les selections de slot n'ajoutent rien (le
// prix du menu est forfaitaire cote serveur). Indicatif.
function menuLineTotal(line) {
var menu = menus.filter(function (m) { return Number(m.id) === Number(line.menuId); })[0] || {};
var base = line.format === 'maxi' ? (menu.price_maxi || 0) : (menu.price_normal || 0);
var extra = modifiersExtra(line.proposable, line.modifiers);
return Number(base) + extra;
}
// Total indicatif du panier : derive des champs qty_<id> (produits simples) +
// des lignes configurees (produits personnalises + menus). Met a jour le pied
// de panier ET le libelle du bouton ("Encaisser X,XX EUR").
function updateTotal() {
var total = 0;
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
var productId = Number(input.dataset.productId);
if (configurableIds[productId]) {
return; // route par la modale -> compte plus bas (pas de double comptage).
}
var quantity = parseInt(input.value, 10);
if (productId > 0 && quantity >= 1) {
total += ((productById[productId] || {}).price || 0) * quantity;
}
});
productLines.forEach(function (line) { total += productLineTotal(line); });
menuLines.forEach(function (line) { total += menuLineTotal(line); });
if (totalValue) {
totalValue.textContent = formatEuros(total);
}
if (submitBtn) {
submitBtn.textContent = 'Encaisser ' + formatEuros(total);
}
}
/* ----------------------------------------------------------------- */
/* Rendu du panier (recap des lignes configurees) */
/* ----------------------------------------------------------------- */
@ -274,6 +388,10 @@
label.textContent = text;
li.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(productLineTotal(line));
li.appendChild(price);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
@ -305,6 +423,10 @@
label.textContent = text;
li.appendChild(label);
var price = el('span', 'order-cart__price');
price.textContent = formatEuros(menuLineTotal(line));
li.appendChild(price);
var removeBtn = el('button', 'btn btn-secondary order-cart__remove');
removeBtn.type = 'button';
removeBtn.textContent = 'Retirer';
@ -320,15 +442,113 @@
if (cartEmpty) {
cartEmpty.style.display = (productLines.length || menuLines.length) ? 'none' : '';
}
updateTotal();
}
/* ----------------------------------------------------------------- */
/* Modales de configuration */
/* ----------------------------------------------------------------- */
// Handlers de modale courants (un jeu a la fois) : retires a la fermeture pour
// ne pas accumuler de listeners a chaque ouverture. lastFocused memorise
// l'element qui avait le focus AVANT l'ouverture, pour le restaurer a la
// fermeture (a11y : le focus ne doit pas retomber en haut de page).
var escHandler = null;
var trapHandler = null;
var lastFocused = null;
// Selecteur des controles focusables d'une modale (boutons, champs, selects ;
// les champs desactives/caches sont exclus). Le trap cycle sur cet ensemble.
var FOCUSABLE = 'button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])';
function focusableIn(root) {
return Array.prototype.slice.call(root.querySelectorAll(FOCUSABLE));
}
function closeComposer() {
if (escHandler) {
doc.removeEventListener('keydown', escHandler);
escHandler = null;
}
if (trapHandler) {
doc.removeEventListener('keydown', trapHandler);
trapHandler = null;
}
modalHost.textContent = '';
modalHost.setAttribute('hidden', '');
// Restaure le focus sur l'element declencheur (bouton Personnaliser/Configurer).
if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
lastFocused = null;
}
// 7c + a11y : monte un panneau dans la modale avec un overlay (clic = fermeture),
// pose role=dialog / aria-modal / aria-labelledby (titre h2), gere Echap, piege
// Tab/Shift+Tab dans le panneau, memorise et restaure le focus. Le panel est
// deja construit par l'appelant ; on ne fait qu'habiller l'ouverture.
function openModal(panel) {
lastFocused = doc.activeElement;
modalHost.textContent = '';
// role=dialog modal + libelle = titre h2 de la modale (id stable, partage
// par les deux composeurs car une seule modale est ouverte a la fois).
panel.setAttribute('role', 'dialog');
panel.setAttribute('aria-modal', 'true');
var titleEl = panel.querySelector('.menu-composer__title');
if (titleEl) {
titleEl.id = 'menu-composer-title';
panel.setAttribute('aria-labelledby', 'menu-composer-title');
}
var overlay = el('div', 'menu-composer__overlay');
// Clic sur le fond (overlay lui-meme, pas un enfant) -> fermeture.
overlay.addEventListener('click', function (event) {
if (event.target === overlay) {
closeComposer();
}
});
overlay.appendChild(panel);
modalHost.appendChild(overlay);
modalHost.removeAttribute('hidden');
escHandler = function (event) {
if (event.key === 'Escape' || event.keyCode === 27) {
closeComposer();
}
};
doc.addEventListener('keydown', escHandler);
// Focus-trap : Tab/Shift+Tab cyclent dans les controles focusables du panel.
trapHandler = function (event) {
if (event.key !== 'Tab' && event.keyCode !== 9) {
return;
}
var focusable = focusableIn(panel);
if (!focusable.length) {
return;
}
var first = focusable[0];
var last = focusable[focusable.length - 1];
var active = doc.activeElement;
if (event.shiftKey && (active === first || !panel.contains(active))) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
doc.addEventListener('keydown', trapHandler);
// Focus sur le premier controle pour la saisie clavier.
var firstControl = focusableIn(panel)[0];
if (firstControl && typeof firstControl.focus === 'function') {
firstControl.focus();
}
}
// Modale d'un produit a la carte : quantite + modificateurs (retrait/ajout).
@ -336,7 +556,6 @@
var proposable = product.modifiers || [];
var state = { quantity: 1, selectedRemove: {}, selectedAdd: {} };
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -389,8 +608,7 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
// Ouvre la modale d'un menu : choix du format, une selection par slot, puis les
@ -405,7 +623,6 @@
}
});
modalHost.textContent = '';
var panel = el('div', 'menu-composer');
var title = el('h2', 'menu-composer__title');
@ -478,6 +695,15 @@
// Modificateurs du burger support (retrait/ajout d'ingredients).
panel.appendChild(renderModifierControls(proposable, state.selectedRemove, state.selectedAdd));
// 7c : message inline au lieu d'un return muet quand un slot requis n'est pas
// choisi. Le <p role=alert> reste present en permanence (non hidden), vide au
// depart : on ne change que textContent a l'erreur, pour fiabiliser l'annonce
// lecteur d'ecran (un element revele apres coup peut ne pas etre annonce).
var inlineError = el('p', 'menu-composer__error');
inlineError.setAttribute('role', 'alert');
inlineError.textContent = '';
panel.appendChild(inlineError);
// Actions : ajouter (si tous les requis choisis) / annuler.
var actions = el('div', 'menu-composer__actions');
var addBtn = el('button', 'btn btn-primary menu-composer__add');
@ -487,6 +713,7 @@
var allRequired = steps.filter(function (s) { return s.isRequired; })
.every(function (s) { return state.selections[s.id] != null; });
if (!allRequired) {
inlineError.textContent = 'Choisissez toutes les options obligatoires avant d\'ajouter.';
return;
}
var selections = [];
@ -517,8 +744,7 @@
actions.appendChild(cancelBtn);
panel.appendChild(actions);
modalHost.appendChild(panel);
modalHost.removeAttribute('hidden');
openModal(panel);
}
/* ----------------------------------------------------------------- */
@ -545,6 +771,34 @@
});
});
// 1 : le total et le libelle du bouton suivent la saisie des quantites des
// produits simples (les lignes configurees rafraichissent via renderCart).
Array.prototype.forEach.call(form.querySelectorAll('.order-qty'), function (input) {
if (configurableIds[Number(input.dataset.productId)]) {
return; // champ desactive (route par la modale).
}
input.addEventListener('input', updateTotal);
input.addEventListener('change', updateTotal);
});
// 7a : le numero de table n'a de sens qu'en sur place -> visible seulement quand
// service_mode = dine_in (au comptoir ; au drive le champ n'existe pas).
function syncServiceTag() {
if (!serviceTagGroup) {
return;
}
var dineIn = serviceMode && serviceMode.value === 'dine_in';
if (dineIn) {
serviceTagGroup.removeAttribute('hidden');
} else {
serviceTagGroup.setAttribute('hidden', '');
}
}
if (serviceMode) {
serviceMode.addEventListener('change', syncServiceTag);
}
syncServiceTag();
form.addEventListener('submit', function () {
serialize();
});

View file

@ -18,6 +18,7 @@ use App\Tests\Support\FakeDatabase;
/**
* Stub OrderQueryRepository : liste canned multi-source (rendu de la liste teste sans
* base). recent() ramene tous canaux ; le controleur filtre par source derivee du chemin.
* paidQueue() ramene la file "En cours" canned, deja filtree par source par l'appelant.
*/
final class StubChannelOrders extends OrderQueryRepository
{
@ -29,6 +30,22 @@ final class StubChannelOrders extends OrderQueryRepository
['order_number' => 'K9', 'source' => 'kiosk', 'service_mode' => 'takeaway', 'service_tag' => null, 'status' => 'paid', 'total_ttc_cents' => 500, 'created_at' => '2026-06-22 10:06:00', 'paid_at' => '2026-06-22 10:06:01'],
];
}
public function paidQueue(array $sources): array
{
// File "En cours" du canal : ne ramene que des commandes dont la source est
// dans $sources (le controleur passe la SEULE source du canal courant). C100 est
// sur place avec un numero de table (12) ; D200 est un drive sans table.
$all = [
['order_number' => 'C100', 'source' => 'counter', 'service_mode' => 'dine_in', 'service_tag' => '12', 'total_ttc_cents' => 890, 'paid_at' => '2026-06-22 10:00:01'],
['order_number' => 'D200', 'source' => 'drive', 'service_mode' => 'drive', 'service_tag' => null, 'total_ttc_cents' => 990, 'paid_at' => '2026-06-22 10:05:01'],
];
return array_values(array_filter(
$all,
static fn (array $o): bool => in_array($o['source'], $sources, true),
));
}
}
final class TestCounterOrderController extends CounterOrderController
@ -456,6 +473,178 @@ final class CounterOrderControllerTest extends TestCase
self::assertFalse($db->wrote('INSERT INTO customer_order'));
}
public function testCounterIndexShowsInProgressQueueSection(): void
{
// 5 : la file "En cours" du canal (paid non livre) apparait en haut, filtree
// a la source counter (C100 present, D200 du drive absent).
$response = $this->controller($this->get('/counter/orders'), $this->permittedDb())->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('En cours', $body);
self::assertStringContainsString('Historique recent', $body);
self::assertStringContainsString('C100', $body);
self::assertStringNotContainsString('D200', $body);
// 4 : la file porte une colonne "Table" et affiche le numero de la commande
// sur place (C100 -> table 12).
self::assertStringContainsString('<th>Table</th>', $body);
self::assertStringContainsString('>12</td>', $body);
}
public function testDriveCreateFreezesServiceModeToDrive(): void
{
// 2 : au drive, service_mode n'est PAS un select editable. Il est fige a 'Drive'
// (affichage) + transmis par un champ cache (un select readonly resterait
// editable, donc on ne s'y fie pas).
$response = $this->controller($this->get('/drive/orders/new'), $this->permittedDb())->create();
self::assertSame(200, $response->status());
$body = $response->body();
// Champ cache porteur de la valeur drive (soumis).
self::assertStringContainsString('type="hidden" name="service_mode" id="service_mode" value="drive"', $body);
// Aucun select de mode au drive (l'affichage est fige).
self::assertStringNotContainsString('<select class="form-input" id="service_mode"', $body);
}
public function testCounterCreateKeepsEditableServiceModeSelect(): void
{
// 2 (contre-exemple) : au comptoir, le select dine_in/takeaway reste editable.
$response = $this->controller($this->get('/counter/orders/new'), $this->permittedDb())->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('<select class="form-input" id="service_mode"', $body);
self::assertStringContainsString('Sur place', $body);
self::assertStringContainsString('A emporter', $body);
}
public function testCreateRendersEditableQuantityWithHintForConfigurableProduct(): void
{
// 4 (progressive enhancement) : le champ quantite d'un produit personnalisable
// est rendu EDITABLE en HTML (repli sans JS marche : commande de base sans
// modificateurs). Le PHP ne pose PAS readonly (c'est le JS qui neutralise au
// cablage). Un indice "via Personnaliser" (cache en HTML) accompagne le champ.
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
];
$db->compositionRows = [
['product_id' => 12, 'ingredient_id' => 3, 'ingredient_name' => 'Oignon', 'is_removable' => 1, 'is_addable' => 0, 'extra_price_cents' => 0, 'quantity_normal' => 1, 'quantity_maxi' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
// Le champ qty est present et EDITABLE (aucun readonly pose par le PHP).
self::assertStringContainsString('name="qty_12"', $body);
self::assertDoesNotMatchRegularExpression('/id="qty_12"[^>]*readonly/', $body);
// Indice de redirection vers la modale (revele par le JS).
self::assertStringContainsString('data-qty-hint="12"', $body);
self::assertStringContainsString('via Personnaliser', $body);
}
public function testCreateGroupsProductsByCategory(): void
{
// 7b : les produits sont regroupes par categorie (sous-titre = category_name).
$db = $this->permittedDb();
$db->productsRows = [
['id' => 12, 'category_id' => 1, 'category_name' => 'Burgers', 'name' => 'Cheeseburger', 'description' => null, 'price_cents' => 890, 'image_path' => null, 'display_order' => 1],
['id' => 22, 'category_id' => 2, 'category_name' => 'Accompagnements', 'name' => 'Frites', 'description' => null, 'price_cents' => 250, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('Burgers', $body);
self::assertStringContainsString('Accompagnements', $body);
}
public function testCreateShowsBothMenuPrices(): void
{
// 6 : la liste des menus affiche les deux prix (Normal / Maxi).
$db = $this->permittedDb();
$db->menusRows = [
['id' => 5, 'category_id' => 1, 'burger_product_id' => 12, 'name' => 'Menu Cheeseburger', 'description' => null, 'price_normal_cents' => 990, 'price_maxi_cents' => 1190, 'image_path' => null, 'display_order' => 1],
];
$response = $this->controller($this->get('/counter/orders/new'), $db)->create();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('9,90 EUR', $body);
self::assertStringContainsString('11,90 EUR', $body);
self::assertStringContainsString('Maxi', $body);
}
public function testStorePassesServiceTagInDineIn(): void
{
// 7a : un numero de table saisi en sur place est transmis a createStaffOrder et
// persiste (service_tag) sur la commande.
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 1]]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'dine_in', 'service_tag' => '12', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertSame('12', $insert['tag']);
}
public function testStoreDropsServiceTagWhenNotDineIn(): void
{
// 7a : un numero de table soumis hors sur place (takeaway) n'est pas transmis ;
// service_tag persiste NULL (la table n'a de sens qu'en sur place).
$db = $this->permittedDb();
$db->productRow = ['id' => 12, 'name' => 'Cheeseburger', 'price_cents' => 890, 'vat_rate' => 100, 'maxi_variant_product_id' => null, 'is_available' => 1];
$db->lastInsertId = 100;
$db->orderByNumberRow = ['id' => 100, 'order_number' => 'C100', 'total_ttc_cents' => 890, 'status' => 'pending_payment'];
$items = json_encode([['type' => 'product', 'product_id' => 12, 'quantity' => 1]]);
$request = $this->post(['_csrf' => $this->csrf, 'service_mode' => 'takeaway', 'service_tag' => '12', 'items_json' => (string) $items], '/counter/orders');
$response = $this->controller($request, $db)->store();
self::assertSame(302, $response->status());
$insert = $this->writeParams($db, 'INSERT INTO customer_order');
self::assertNull($insert['tag']);
}
public function testNavRoutesDriveRoleToDriveLanding(): void
{
// 3 : le lien "Saisie commande" du layout pointe vers le canal du role courant.
// Un equipier drive (role.order_source = drive, remonte par displayInfo) est
// route vers /drive/orders.
$db = $this->permittedDb();
$db->userDisplayRow = ['first_name' => 'Dana', 'last_name' => 'D', 'role_label' => 'Drive', 'order_source' => 'drive'];
$response = $this->controller($this->get('/drive/orders'), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('href="/drive/orders" class="sidebar-item active">Saisie commande', $body);
}
public function testNavRoutesCounterRoleToCounterLanding(): void
{
// 3 (contre-exemple) : un role comptoir (order_source counter / NULL) est route
// vers /counter/orders.
$db = $this->permittedDb();
$db->userDisplayRow = ['first_name' => 'Sam', 'last_name' => 'C', 'role_label' => 'Comptoir', 'order_source' => 'counter'];
$response = $this->controller($this->get('/counter/orders'), $db)->index();
self::assertSame(200, $response->status());
$body = $response->body();
self::assertStringContainsString('href="/counter/orders" class="sidebar-item active">Saisie commande', $body);
}
/**
* Parametres lies de la premiere ecriture dont le SQL contient $needle.
*

View file

@ -27,20 +27,38 @@ final class UserDirectoryTest extends TestCase
'last_name' => 'J',
'email' => 'corentin@wakdo.local',
'role_label' => 'Administrateur',
'order_source' => null,
];
self::assertSame(
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local'],
['name' => 'Corentin J', 'role_label' => 'Administrateur', 'email' => 'corentin@wakdo.local', 'order_source' => ''],
(new UserDirectory($this->db))->displayInfo(7),
);
}
public function testDisplayInfoExposesOrderSourceForChannelRoles(): void
{
// order_source remonte du role : sert au layout a router "Saisie commande".
$this->db->userDisplayRow = [
'first_name' => 'Dana',
'last_name' => 'D',
'email' => 'dana@wakdo.local',
'role_label' => 'Drive Staff',
'order_source' => 'drive',
];
self::assertSame(
['name' => 'Dana D', 'role_label' => 'Drive Staff', 'email' => 'dana@wakdo.local', 'order_source' => 'drive'],
(new UserDirectory($this->db))->displayInfo(8),
);
}
public function testDisplayInfoDefaultsWhenAbsent(): void
{
$this->db->userDisplayRow = null;
self::assertSame(
['name' => 'Utilisateur', 'role_label' => '', 'email' => ''],
['name' => 'Utilisateur', 'role_label' => '', 'email' => '', 'order_source' => ''],
(new UserDirectory($this->db))->displayInfo(999),
);
}

View file

@ -7,7 +7,9 @@
* - menu non configurable (slot_type non gere) ignore (anti-perte silencieuse)
*
* Le serveur revalide la forme (RG-T18), revalide chaque modificateur (resolveModifiers)
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise, pas un prix.
* et recalcule les prix (RG-T16) : on n'asserte que la FORME emise. Le prix affiche
* cote client (total + libelle du bouton) est INDICATIF : on verrouille seulement
* l'affichage local (somme price + surcouts), pas une verite metier serveur.
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
@ -52,13 +54,20 @@ function setup(menus = MENUS) {
.map(m => `<li><button class="menu-configure" type="button" data-menu-id="${m.id}">Configurer</button></li>`)
.join('');
// qty_<id> pour tous les produits (repli sans JS) ; bouton "Personnaliser" pour
// ceux dont la recette offre des modificateurs (calque la vue new.php).
// ceux dont la recette offre des modificateurs (calque la vue new.php). Progressive
// enhancement (etape 4) : le champ qty est rendu EDITABLE en HTML pour TOUS les
// produits ; c'est le JS qui neutralise le champ d'un produit configurable au cablage
// et revele l'indice "via Personnaliser" (span data-qty-hint, hidden en HTML).
const productRows = PRODUCTS
.map(p => {
const configure = (p.modifiers && p.modifiers.length)
const hasMods = p.modifiers && p.modifiers.length;
const configure = hasMods
? `<button class="product-configure" type="button" data-product-id="${p.id}">Personnaliser</button>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${configure}`;
const hint = hasMods
? `<span class="order-qty-hint" data-qty-hint="${p.id}" hidden>via Personnaliser</span>`
: '';
return `<input class="order-qty" type="number" id="qty_${p.id}" name="qty_${p.id}" data-product-id="${p.id}" value="0">${hint}${configure}`;
})
.join('');
const dom = new JSDOM(
@ -66,10 +75,13 @@ function setup(menus = MENUS) {
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRODUCTS)}' data-menus='${JSON.stringify(menus)}'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <select id="service_mode" name="service_mode"><option value="dine_in" selected>Sur place</option><option value="takeaway">A emporter</option></select>' +
' <div id="service_tag_group"><input type="text" id="service_tag" name="service_tag"></div>' +
productRows +
' <ul id="menu-list">' + menuItems + '</ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <button type="submit">Encaisser</button>' +
' <p id="order-total">Total : <span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
@ -271,6 +283,210 @@ test('configuration menu avec modificateur burger -> item menu porte modifiers:[
assert.deepEqual(items[0].modifiers, [{ ingredient_id: 3, action: 'remove' }]);
});
test('total + bouton : produit simple (Frites 2,50 x2) -> 5,00 EUR affiche', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_22'); // Frites, 250c
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
assert.equal(doc.getElementById('order-total-value').textContent, '5,00 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 5,00 EUR');
});
test('total : produit personnalise avec ajout (Cheeseburger 8,90 + Bacon 0,50) -> 9,40 EUR', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.product-configure[data-product-id="12"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
const addBox = modal.querySelector('.menu-composer__modifier-add[data-ingredient-id="8"]');
addBox.checked = true;
addBox.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Prix de ligne affiche dans le panier.
assert.equal(doc.querySelector('.order-cart__price').textContent, '9,40 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '9,40 EUR');
});
test('total : menu Maxi (11,90) inclus dans le total de ligne', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
const maxiRadio = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__format-input'),
r => r.value === 'maxi',
);
maxiRadio.checked = true;
maxiRadio.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
assert.equal(doc.querySelector('.order-cart__price').textContent, '11,90 EUR');
assert.equal(doc.getElementById('order-total-value').textContent, '11,90 EUR');
});
test('numero de table : masque hors sur place, visible en sur place (toggle service_mode)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const group = doc.getElementById('service_tag_group');
const select = doc.getElementById('service_mode');
// Init : dine_in pre-selectionne -> visible.
assert.equal(group.hasAttribute('hidden'), false);
select.value = 'takeaway';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), true);
select.value = 'dine_in';
select.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
assert.equal(group.hasAttribute('hidden'), false);
});
test('modale menu : slot requis non choisi -> message inline, pas d ajout muet', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Vide un slot requis (drink, slot 1) en le passant a une valeur absente : on force
// l'etat en deselectionnant via un select dont l'option vide n'existe pas. On
// simule en retirant la selection requise par un slot side (16) mis a vide n'est pas
// possible (requis) ; on retire plutot la pre-selection en posant une valeur hors options.
// Plus simple : on supprime l'option pre-cochee du slot requis 'drink' (1) en le
// forcant a une chaine vide via le select (un slot requis n'a pas d'option Sans, mais
// jsdom autorise l'affectation d'une value vide -> change supprime la selection).
const drinkSelect = Array.prototype.find.call(
modal.querySelectorAll('.menu-composer__slot-select'),
s => s.dataset.slotId === '1',
);
drinkSelect.value = '';
drinkSelect.dispatchEvent(new dom.window.Event('change', { bubbles: true }));
// Le <p role=alert> est present des l'ouverture (vide), avant toute erreur.
const errAtOpen = modal.querySelector('.menu-composer__error');
assert.ok(errAtOpen);
assert.equal(errAtOpen.getAttribute('role'), 'alert');
assert.equal(errAtOpen.textContent, '');
assert.equal(errAtOpen.hasAttribute('hidden'), false); // present en permanence (a11y)
modal.querySelector('.menu-composer__add').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
// Modale encore ouverte, message inline renseigne (textContent), aucune ligne.
assert.equal(modal.hasAttribute('hidden'), false);
assert.notEqual(errAtOpen.textContent, '');
assert.equal(doc.querySelector('.order-cart__line'), null);
});
test('produit personnalisable : champ qty editable en HTML, desactive par JS + indice revele', () => {
const dom = setup();
const doc = dom.window.document;
// Avant init : le champ qty d'un produit a modificateurs est editable (repli sans JS)
// et l'indice "via Personnaliser" est cache.
const qty = doc.getElementById('qty_12');
const hint = doc.querySelector('[data-qty-hint="12"]');
assert.equal(qty.disabled, false);
assert.equal(hint.hidden, true);
counterOrder.init(doc);
// Apres init (JS present) : champ neutralise et indice revele.
assert.equal(qty.disabled, true);
assert.equal(hint.hidden, false);
// Un produit SANS modificateur reste editable (pas d'indice).
assert.equal(doc.getElementById('qty_22').disabled, false);
assert.equal(doc.querySelector('[data-qty-hint="22"]'), null);
});
test('modale : focus restaure sur le bouton declencheur a la fermeture', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
const trigger = doc.querySelector('.menu-configure[data-menu-id="5"]');
trigger.focus();
assert.equal(doc.activeElement, trigger);
trigger.dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
// Le focus est entre dans la modale (plus sur le bouton declencheur).
assert.notEqual(doc.activeElement, trigger);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
// Ferme -> focus restaure sur le declencheur.
assert.equal(doc.activeElement, trigger);
});
test('modale : panel porte role=dialog, aria-modal et aria-labelledby (titre)', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const panel = doc.querySelector('.menu-composer');
assert.equal(panel.getAttribute('role'), 'dialog');
assert.equal(panel.getAttribute('aria-modal'), 'true');
const labelledby = panel.getAttribute('aria-labelledby');
assert.ok(labelledby);
const title = doc.getElementById(labelledby);
assert.ok(title);
assert.equal(title.classList.contains('menu-composer__title'), true);
});
test('total : separateur de milliers aligne sur PHP (1 234,50 EUR)', () => {
// Produit a 617,25 EUR (61725c) x2 = 1 234,50 EUR -> espace separateur de milliers.
const PRICEY = [{ id: 99, name: 'Plateau', price: 61725, modifiers: [] }];
const dom = new JSDOM(
'<!DOCTYPE html><html><body>' +
'<form id="counter-order-form" method="post" action="/counter/orders" ' +
` data-products='${JSON.stringify(PRICEY)}' data-menus='[]'>` +
' <input type="hidden" name="items_json" id="items_json" value="">' +
' <input class="order-qty" type="number" id="qty_99" name="qty_99" data-product-id="99" value="0">' +
' <ul id="menu-list"></ul>' +
' <ul id="order-cart"><li id="order-cart-empty">Panier vide.</li></ul>' +
' <p><span id="order-total-value">0,00 EUR</span></p>' +
' <button type="submit" id="order-submit">Encaisser 0,00 EUR</button>' +
'</form>' +
'<div id="menu-composer-modal" hidden></div>' +
'</body></html>',
);
const doc = dom.window.document;
counterOrder.init(doc);
const qty = doc.getElementById('qty_99');
qty.value = '2';
qty.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
assert.equal(doc.getElementById('order-total-value').textContent, '1 234,50 EUR');
assert.equal(doc.getElementById('order-submit').textContent, 'Encaisser 1 234,50 EUR');
});
test('modale : touche Echap ferme la modale', () => {
const dom = setup();
const doc = dom.window.document;
counterOrder.init(doc);
doc.querySelector('.menu-configure[data-menu-id="5"]').dispatchEvent(new dom.window.Event('click', { bubbles: true }));
const modal = doc.getElementById('menu-composer-modal');
assert.equal(modal.hasAttribute('hidden'), false);
doc.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
assert.equal(modal.hasAttribute('hidden'), true);
});
test('composerSteps: slot_type non gere (dessert) ignore, slots tries par display_order', () => {
const productById = {};
PRODUCTS.forEach(p => { productById[p.id] = p; });