From 352355f5a577a293107c999294c2d9a059a06b2c Mon Sep 17 00:00:00 2001 From: Corentin JOGUET Date: Wed, 24 Jun 2026 12:05:25 +0200 Subject: [PATCH] feat(back-office): refonte saisie commande comptoir/drive (prix, verrou, nav, file) (#100) --- src/app/Auth/UserDirectory.php | 15 +- src/app/Catalogue/ProductRepository.php | 4 +- src/app/Controllers/AdminController.php | 5 + .../Controllers/CounterOrderController.php | 26 +- src/app/Views/admin/counter/index.php | 53 +++- src/app/Views/admin/counter/new.php | 164 +++++++---- src/app/Views/admin/layout.php | 14 +- src/public/admin/assets/css/admin.css | 120 ++++++++ src/public/admin/assets/js/counter-order.js | 272 +++++++++++++++++- .../Unit/Admin/CounterOrderControllerTest.php | 189 ++++++++++++ tests/Unit/Auth/UserDirectoryTest.php | 30 +- tests/js/counter-order.test.js | 226 ++++++++++++++- 12 files changed, 1027 insertions(+), 91 deletions(-) diff --git a/src/app/Auth/UserDirectory.php b/src/app/Auth/UserDirectory.php index 96af531..46a2222 100644 --- a/src/app/Auth/UserDirectory.php +++ b/src/app/Auth/UserDirectory.php @@ -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], ); @@ -33,9 +37,10 @@ final class UserDirectory $name = trim($first . ' ' . $last); return [ - 'name' => $name !== '' ? $name : 'Utilisateur', - 'role_label' => is_string($row['role_label'] ?? null) ? $row['role_label'] : '', - 'email' => is_string($row['email'] ?? null) ? $row['email'] : '', + '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'] : '', ]; } } diff --git a/src/app/Catalogue/ProductRepository.php b/src/app/Catalogue/ProductRepository.php index 19769ac..0029b1b 100644 --- a/src/app/Catalogue/ProductRepository.php +++ b/src/app/Catalogue/ProductRepository.php @@ -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', ); } diff --git a/src/app/Controllers/AdminController.php b/src/app/Controllers/AdminController.php index dce2a93..71ad271 100644 --- a/src/app/Controllers/AdminController.php +++ b/src/app/Controllers/AdminController.php @@ -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(), ]; diff --git a/src/app/Controllers/CounterOrderController.php b/src/app/Controllers/CounterOrderController.php index c21f377..73c035b 100644 --- a/src/app/Controllers/CounterOrderController.php +++ b/src/app/Controllers/CounterOrderController.php @@ -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, + '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_ 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); } diff --git a/src/app/Views/admin/counter/index.php b/src/app/Views/admin/counter/index.php index 025bda8..d6ba9cb 100644 --- a/src/app/Views/admin/counter/index.php +++ b/src/app/Views/admin/counter/index.php @@ -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> $orders + * @var list> $orders historique recent (tous statuts) + * @var list> $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> $rows */ $rows = isset($orders) && is_array($orders) ? $orders : []; +/** @var list> $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

Nouvelle commande -

commande(s) recente(s)

+

En cours

+

commande(s) a servir

+ +

Aucune commande en cours.

+ + + + + + + + + + + + + + + + + + + + + + + +
NumeroModeTableTotalPayee a
+ + +

Historique recent

+

commande(s) recente(s)

Aucune commande pour ce canal.

diff --git a/src/app/Views/admin/counter/new.php b/src/app/Views/admin/counter/new.php index 9b7771a..bceb5d9 100644 --- a/src/app/Views/admin/counter/new.php +++ b/src/app/Views/admin/counter/new.php @@ -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_` 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_` 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> $products * @var list> $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> $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; +} ?>