diff --git a/lib/Components/appDrawer.dart b/lib/Components/appDrawer.dart index 90cf050..8da51b6 100644 --- a/lib/Components/appDrawer.dart +++ b/lib/Components/appDrawer.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:youmazgestion/Views/Dashboard.dart'; +import 'package:youmazgestion/Views/DemandeTransfert.dart'; import 'package:youmazgestion/Views/HandleProduct.dart'; import 'package:youmazgestion/Views/RoleListPage.dart'; import 'package:youmazgestion/Views/commandManagement.dart'; @@ -15,6 +16,10 @@ import 'package:youmazgestion/Views/registrationPage.dart'; import 'package:youmazgestion/accueil.dart'; import 'package:youmazgestion/controller/userController.dart'; import 'package:youmazgestion/Views/gestion_point_de_vente.dart'; +// ✅ NOUVEAU: Imports pour les sorties personnelles +import 'package:youmazgestion/Views/demande_sortie_personnelle_page.dart'; +import 'package:youmazgestion/Views/approbation_sorties_page.dart'; +import 'package:youmazgestion/Views/historique_sorties_personnelles_page.dart'; class CustomDrawer extends StatelessWidget { final UserController userController = Get.find(); @@ -108,6 +113,24 @@ class CustomDrawer extends StatelessWidget { } } + // ✅ NOUVEAU: Ajouter le menu Demande Assignation pour Super Admin uniquement + if (userController.role == 'Super Admin') { + // Vérifier si le menu n'existe pas déjà + final bool demandeAssignationExists = validMenus.any((menu) => menu['route'] == '/demande-assignation'); + + if (!demandeAssignationExists) { + validMenus.add({ + 'id': 'super_admin_demande_assignation', + 'name': 'Demande Assignation', + 'route': '/demande-assignation', + }); + print("✅ Menu 'Demande Assignation' ajouté pour Super Admin"); + } + } + + // ✅ NOUVEAU: Ajouter les menus de sorties personnelles + _addSortiesPersonnellesMenus(validMenus); + // Afficher les statistiques de validation if (invalidMenus.isNotEmpty) { print("📊 CustomDrawer: ${validMenus.length} menus valides, ${invalidMenus.length} invalides"); @@ -143,8 +166,10 @@ class CustomDrawer extends StatelessWidget { 'GESTION UTILISATEURS': [], 'GESTION PRODUITS': [], 'GESTION COMMANDES': [], + 'GESTION STOCK': [], // ✅ NOUVEAU: Catégorie pour les sorties personnelles 'RAPPORTS': [], 'ADMINISTRATION': [], + 'SUPER ADMIN': [], // ✅ NOUVEAU: Catégorie spéciale pour Super Admin }; // Accueil toujours en premier @@ -173,13 +198,17 @@ class CustomDrawer extends StatelessWidget { categorizedMenus['GESTION UTILISATEURS']!.add(menu); break; case '/ajouter-produit': - case '/gestion-stock': categorizedMenus['GESTION PRODUITS']!.add(menu); break; case '/nouvelle-commande': case '/gerer-commandes': categorizedMenus['GESTION COMMANDES']!.add(menu); break; + case '/gestion-stock': + case '/demande-sortie-personnelle': + case '/historique-sorties-personnelles': + categorizedMenus['GESTION STOCK']!.add(menu); + break; case '/bilan': case '/historique': categorizedMenus['RAPPORTS']!.add(menu); @@ -188,6 +217,11 @@ class CustomDrawer extends StatelessWidget { case '/points-de-vente': categorizedMenus['ADMINISTRATION']!.add(menu); break; + case '/demande-assignation': + case '/approbation-sorties': + // ✅ NOUVEAU: Placer dans la catégorie SUPER ADMIN + categorizedMenus['SUPER ADMIN']!.add(menu); + break; default: // Menu non catégorisé print("⚠️ Menu non catégorisé: $route"); @@ -198,6 +232,11 @@ class CustomDrawer extends StatelessWidget { // Ajouter les catégories avec leurs menus categorizedMenus.forEach((categoryName, menus) { if (menus.isNotEmpty) { + // ✅ Afficher la catégorie SUPER ADMIN seulement pour les Super Admin + if (categoryName == 'SUPER ADMIN' && userController.role != 'Super Admin') { + return; // Skip cette catégorie + } + drawerItems.add(_buildCategoryHeader(categoryName)); for (var menu in menus) { drawerItems.add(_buildDrawerItemFromMenu(menu)); @@ -208,6 +247,44 @@ class CustomDrawer extends StatelessWidget { return drawerItems; } + /// ✅ NOUVEAU: Méthode pour ajouter les menus de sorties personnelles + void _addSortiesPersonnellesMenus(List> validMenus) { + // Menu Demande Sortie Personnelle - Accessible à tous les utilisateurs connectés + final bool demandeSortieExists = validMenus.any((menu) => menu['route'] == '/demande-sortie-personnelle'); + if (!demandeSortieExists) { + validMenus.add({ + 'id': 'demande_sortie_personnelle', + 'name': 'Demande sortie personnelle', + 'route': '/demande-sortie-personnelle', + }); + print("✅ Menu 'Demande sortie personnelle' ajouté pour tous les utilisateurs"); + } + + // Menu Historique Sorties Personnelles - Accessible à tous les utilisateurs connectés + final bool historiqueSortiesExists = validMenus.any((menu) => menu['route'] == '/historique-sorties-personnelles'); + if (!historiqueSortiesExists) { + validMenus.add({ + 'id': 'historique_sorties_personnelles', + 'name': 'Historique sorties personnelles', + 'route': '/historique-sorties-personnelles', + }); + print("✅ Menu 'Historique sorties personnelles' ajouté pour tous les utilisateurs"); + } + + // Menu Approbation Sorties - Accessible SEULEMENT aux Super Admin + if (userController.role == 'Super Admin') { + final bool approbationSortiesExists = validMenus.any((menu) => menu['route'] == '/approbation-sorties'); + if (!approbationSortiesExists) { + validMenus.add({ + 'id': 'approbation_sorties', + 'name': 'Approuver sorties', + 'route': '/approbation-sorties', + }); + print("✅ Menu 'Approuver sorties' ajouté pour Super Admin"); + } + } + } + /// ✅ CORRIGÉ: Construction d'un item de menu avec validation Widget _buildDrawerItemFromMenu(Map menu) { // 🛡️ VALIDATION: Vérification des types avec gestion des null @@ -289,6 +366,27 @@ class CustomDrawer extends StatelessWidget { 'color': Colors.blueGrey, 'widget': const AjoutPointDeVentePage(), }, + '/demande-assignation': { + 'icon': Icons.assignment_turned_in, + 'color': Colors.deepOrange, + 'widget': const GestionTransfertsPage(), + }, + // ✅ NOUVEAU: Routes pour les sorties personnelles + '/demande-sortie-personnelle': { + 'icon': Icons.person_remove, + 'color': Colors.teal, + 'widget': const DemandeSortiePersonnellePage(), + }, + '/approbation-sorties': { + 'icon': Icons.approval, + 'color': Colors.red, + 'widget': const ApprobationSortiesPage(), + }, + '/historique-sorties-personnelles': { + 'icon': Icons.history_edu, + 'color': Colors.indigo, + 'widget': const HistoriqueSortiesPersonnellesPage(), + }, }; final routeData = routeMapping[route]; @@ -315,7 +413,51 @@ class CustomDrawer extends StatelessWidget { color: routeData['color'] as Color, ), title: Text(name), - trailing: const Icon(Icons.chevron_right, color: Colors.grey), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // ✅ NOUVEAU: Badge Super Admin pour les menus réservés + if ((route == '/demande-assignation' || route == '/approbation-sorties') && + userController.role == 'Super Admin') ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade600, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'ADMIN', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 4), + ], + // ✅ NOUVEAU: Badge "Tous" pour les menus accessibles à tous + if (route == '/demande-sortie-personnelle' || route == '/historique-sorties-personnelles') ...[ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.shade600, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'TOUS', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 4), + ], + const Icon(Icons.chevron_right, color: Colors.grey), + ], + ), onTap: () { final widget = routeData['widget']; if (widget != null) { @@ -335,13 +477,34 @@ class CustomDrawer extends StatelessWidget { Widget _buildCategoryHeader(String categoryName) { return Padding( padding: const EdgeInsets.only(left: 20, top: 15, bottom: 5), - child: Text( - categoryName, - style: const TextStyle( - color: Colors.grey, - fontSize: 12, - fontWeight: FontWeight.bold, - ), + child: Row( + children: [ + Text( + categoryName, + style: const TextStyle( + color: Colors.grey, + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + // ✅ NOUVEAU: Icône spéciale pour les catégories importantes + if (categoryName == 'SUPER ADMIN') ...[ + const SizedBox(width: 8), + Icon( + Icons.security, + size: 14, + color: Colors.red.shade600, + ), + ], + if (categoryName == 'GESTION STOCK') ...[ + const SizedBox(width: 8), + Icon( + Icons.inventory_2, + size: 14, + color: Colors.teal.shade600, + ), + ], + ], ), ); } @@ -378,12 +541,35 @@ class CustomDrawer extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - Text( - controller.role.isNotEmpty ? controller.role : 'Aucun rôle', - style: const TextStyle( - color: Colors.white70, - fontSize: 12, - ), + Row( + children: [ + Text( + controller.role.isNotEmpty ? controller.role : 'Aucun rôle', + style: const TextStyle( + color: Colors.white70, + fontSize: 12, + ), + ), + // ✅ NOUVEAU: Badge Super Admin dans le header + if (controller.role == 'Super Admin') ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.red.shade600, + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'SA', + style: TextStyle( + color: Colors.white, + fontSize: 8, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], ), if (controller.pointDeVenteDesignation.isNotEmpty) ...[ const SizedBox(height: 2), @@ -454,6 +640,13 @@ class CustomDrawer extends StatelessWidget { debugInfo += "[$i] ID:${menu['id']}, Name:'${menu['name']}', Route:'${menu['route']}'\n"; } + // ✅ NOUVEAU: Ajouter info sur les menus sorties personnelles + debugInfo += "\n--- MENUS SORTIES PERSONNELLES ---\n"; + debugInfo += "Rôle actuel: ${controller.role}\n"; + debugInfo += "Menu Demande Sortie: Accessible à tous\n"; + debugInfo += "Menu Historique: Accessible à tous\n"; + debugInfo += "Menu Approbation: ${controller.role == 'Super Admin' ? 'Accessible (Super Admin)' : 'Non accessible'}\n"; + Get.dialog( AlertDialog( title: const Text("Debug Menus"), diff --git a/lib/Models/users.dart b/lib/Models/users.dart index 273a4a2..62a11f5 100644 --- a/lib/Models/users.dart +++ b/lib/Models/users.dart @@ -1,4 +1,4 @@ -// Models/users.dart - Version corrigée +// 1. Models/users.dart - Version corrigée class Users { int? id; String name; @@ -22,10 +22,12 @@ class Users { this.pointDeVenteId, }); + // ✅ CORRIGÉ: Méthode toMap() qui correspond exactement aux colonnes de la DB Map toMap() { return { + 'id': id, // ✅ Inclure l'ID pour les updates 'name': name, - 'lastname': lastName, // Correspond à la colonne DB + 'lastname': lastName, // ✅ Correspond à la colonne DB 'email': email, 'password': password, 'username': username, @@ -34,9 +36,10 @@ class Users { }; } - Map toMapWithId() { + // ✅ Méthode pour créer un map sans l'ID (pour les insertions) + Map toMapForInsert() { final map = toMap(); - if (id != null) map['id'] = id; + map.remove('id'); return map; } @@ -44,7 +47,7 @@ class Users { return Users( id: map['id'] as int?, name: map['name'] as String, - lastName: map['lastname'] as String, // Correspond à la colonne DB + lastName: map['lastname'] as String, // ✅ Correspond à la colonne DB email: map['email'] as String, password: map['password'] as String, username: map['username'] as String, @@ -55,4 +58,4 @@ class Users { } String get role => roleName ?? ''; -} +} \ No newline at end of file diff --git a/lib/Services/Script.sql b/lib/Services/Script.sql index 84f9bf8..7cd8f05 100644 --- a/lib/Services/Script.sql +++ b/lib/Services/Script.sql @@ -301,4 +301,59 @@ FROM role_menu_permissions rmp INNER JOIN roles r ON rmp.role_id = r.id WHERE r.designation = 'Super Admin'; -SELECT 'Script terminé avec succès!' as resultat; \ No newline at end of file +SELECT 'Script terminé avec succès!' as resultat; + + + + +CREATE TABLE `demandes_transfert` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `produit_id` int(11) NOT NULL, + `point_de_vente_source_id` int(11) NOT NULL, + `point_de_vente_destination_id` int(11) NOT NULL, + `demandeur_id` int(11) NOT NULL, + `validateur_id` int(11) DEFAULT NULL, + `quantite` int(11) NOT NULL DEFAULT 1, + `statut` enum('en_attente','validee','refusee') NOT NULL DEFAULT 'en_attente', + `date_demande` datetime NOT NULL, + `date_validation` datetime DEFAULT NULL, + `notes` text DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `produit_id` (`produit_id`), + KEY `point_de_vente_source_id` (`point_de_vente_source_id`), + KEY `point_de_vente_destination_id` (`point_de_vente_destination_id`), + KEY `demandeur_id` (`demandeur_id`), + KEY `validateur_id` (`validateur_id`), + CONSTRAINT `demandes_transfert_ibfk_1` FOREIGN KEY (`produit_id`) REFERENCES `products` (`id`), + CONSTRAINT `demandes_transfert_ibfk_2` FOREIGN KEY (`point_de_vente_source_id`) REFERENCES `points_de_vente` (`id`), + CONSTRAINT `demandes_transfert_ibfk_3` FOREIGN KEY (`point_de_vente_destination_id`) REFERENCES `points_de_vente` (`id`), + CONSTRAINT `demandes_transfert_ibfk_4` FOREIGN KEY (`demandeur_id`) REFERENCES `users` (`id`), + CONSTRAINT `demandes_transfert_ibfk_5` FOREIGN KEY (`validateur_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + + + + +-- Table pour tracer les sorties de stock personnelles +CREATE TABLE `sorties_stock_personnelles` ( + `id` int(11) NOT NULL AUTO_INCREMENT, + `produit_id` int(11) NOT NULL, + `admin_id` int(11) NOT NULL, + `quantite` int(11) NOT NULL DEFAULT 1, + `motif` varchar(500) NOT NULL, + `date_sortie` datetime NOT NULL, + `point_de_vente_id` int(11) DEFAULT NULL, + `notes` text DEFAULT NULL, + `statut` enum('en_attente','approuvee','refusee') NOT NULL DEFAULT 'en_attente', + `approbateur_id` int(11) DEFAULT NULL, + `date_approbation` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `produit_id` (`produit_id`), + KEY `admin_id` (`admin_id`), + KEY `point_de_vente_id` (`point_de_vente_id`), + KEY `approbateur_id` (`approbateur_id`), + CONSTRAINT `sorties_personnelles_ibfk_1` FOREIGN KEY (`produit_id`) REFERENCES `products` (`id`), + CONSTRAINT `sorties_personnelles_ibfk_2` FOREIGN KEY (`admin_id`) REFERENCES `users` (`id`), + CONSTRAINT `sorties_personnelles_ibfk_3` FOREIGN KEY (`point_de_vente_id`) REFERENCES `points_de_vente` (`id`), + CONSTRAINT `sorties_personnelles_ibfk_4` FOREIGN KEY (`approbateur_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; \ No newline at end of file diff --git a/lib/Services/stock_managementDatabase.dart b/lib/Services/stock_managementDatabase.dart index c87904d..b0821a9 100644 --- a/lib/Services/stock_managementDatabase.dart +++ b/lib/Services/stock_managementDatabase.dart @@ -234,21 +234,109 @@ class AppDatabase { return result.insertId!; } + // ✅ CORRIGÉ: Méthode updateUser simplifiée et corrigée Future updateUser(Users user) async { final db = await database; - final userMap = user.toMap(); - final id = userMap.remove('id'); - final setClause = userMap.keys.map((key) => '$key = ?').join(', '); - final values = [...userMap.values, id]; + try { + print("🔄 Mise à jour utilisateur ID: ${user.id}"); + print("Données: ${user.toMap()}"); + + final result = await db.query(''' + UPDATE users + SET + name = ?, + lastname = ?, + email = ?, + username = ?, + password = ?, + role_id = ?, + point_de_vente_id = ? + WHERE id = ? + ''', [ + user.name, + user.lastName, + user.email, + user.username, + user.password, + user.roleId, + user.pointDeVenteId, + user.id + ]); + + print("✅ Utilisateur mis à jour. Lignes affectées: ${result.affectedRows}"); + return result.affectedRows!; + + } catch (e) { + print("❌ Erreur lors de la mise à jour de l'utilisateur: $e"); + rethrow; + } + } + // ✅ AJOUTÉ: Méthode pour vérifier si l'utilisateur existe + Future userExists(int userId) async { + final db = await database; + try { + final result = await db.query( + 'SELECT COUNT(*) as count FROM users WHERE id = ?', + [userId] + ); + return (result.first['count'] as int) > 0; + } catch (e) { + print("❌ Erreur vérification existence utilisateur: $e"); + return false; + } + } + + // ✅ AJOUTÉ: Méthode pour vérifier les contraintes avant mise à jour + Future validateUserUpdate(Users user) async { + final db = await database; - final result = await db.query( - 'UPDATE users SET $setClause WHERE id = ?', - values - ); - return result.affectedRows!; + try { + // Vérifier si l'email existe déjà pour un autre utilisateur + final emailCheck = await db.query( + 'SELECT COUNT(*) as count FROM users WHERE email = ? AND id != ?', + [user.email, user.id] + ); + if ((emailCheck.first['count'] as int) > 0) { + return 'Cet email est déjà utilisé par un autre utilisateur'; + } + + // Vérifier si le username existe déjà pour un autre utilisateur + final usernameCheck = await db.query( + 'SELECT COUNT(*) as count FROM users WHERE username = ? AND id != ?', + [user.username, user.id] + ); + if ((usernameCheck.first['count'] as int) > 0) { + return 'Ce nom d\'utilisateur est déjà utilisé'; + } + + // Vérifier si le rôle existe + final roleCheck = await db.query( + 'SELECT COUNT(*) as count FROM roles WHERE id = ?', + [user.roleId] + ); + if ((roleCheck.first['count'] as int) == 0) { + return 'Le rôle sélectionné n\'existe pas'; + } + + // Vérifier si le point de vente existe (si spécifié) + if (user.pointDeVenteId != null && user.pointDeVenteId! > 0) { + final pointDeVenteCheck = await db.query( + 'SELECT COUNT(*) as count FROM points_de_vente WHERE id = ?', + [user.pointDeVenteId] + ); + if ((pointDeVenteCheck.first['count'] as int) == 0) { + return 'Le point de vente sélectionné n\'existe pas'; + } + } + + return null; // Aucune erreur + } catch (e) { + return 'Erreur lors de la validation: $e'; + } } + Future deleteUser(int id) async { final db = await database; final result = await db.query('DELETE FROM users WHERE id = ?', [id]); @@ -1059,12 +1147,206 @@ Future> getDetailsCommande(int commandeId) async { return result.affectedRows!; } - Future deletePointDeVente(int id) async { - final db = await database; - final result = await db.query('DELETE FROM points_de_vente WHERE id = ?', [id]); + // Future deletePointDeVente(int id) async { + // final db = await database; + // final result = await db.query('DELETE FROM points_de_vente WHERE id = ?', [id]); + // return result.affectedRows!; + // } +// Dans votre classe AppDatabase, remplacez la méthode deletePointDeVente par ceci : + +// SOLUTION 1: Vérification avant suppression avec gestion des produits +Future deletePointDeVente(int id) async { + final db = await database; + + try { + await db.query('START TRANSACTION'); + + // 1. Vérifier s'il y a des produits liés à ce point de vente + final produitsLies = await db.query( + 'SELECT COUNT(*) as count FROM products WHERE point_de_vente_id = ?', + [id] + ); + final nombreProduits = produitsLies.first['count'] as int; + + if (nombreProduits > 0) { + // Option A: Transférer les produits vers un point de vente par défaut + // Récupérer le premier point de vente disponible (ou créer un "point de vente général") + final pointsDeVente = await db.query( + 'SELECT id FROM points_de_vente WHERE id != ? ORDER BY id ASC LIMIT 1', + [id] + ); + + if (pointsDeVente.isNotEmpty) { + final pointDeVenteParDefaut = pointsDeVente.first['id'] as int; + + // Transférer tous les produits vers le point de vente par défaut + await db.query( + 'UPDATE products SET point_de_vente_id = ? WHERE point_de_vente_id = ?', + [pointDeVenteParDefaut, id] + ); + + print("$nombreProduits produits transférés vers le point de vente ID $pointDeVenteParDefaut"); + } else { + // Si aucun autre point de vente, créer un point de vente "Général" + final result = await db.query( + 'INSERT INTO points_de_vente (nom) VALUES (?)', + ['Général'] + ); + final nouveauPointId = result.insertId!; + + await db.query( + 'UPDATE products SET point_de_vente_id = ? WHERE point_de_vente_id = ?', + [nouveauPointId, id] + ); + + print("Point de vente 'Général' créé et $nombreProduits produits transférés"); + } + } + + // 2. Maintenant supprimer le point de vente + final deleteResult = await db.query( + 'DELETE FROM points_de_vente WHERE id = ?', + [id] + ); + + await db.query('COMMIT'); + return deleteResult.affectedRows!; + + } catch (e) { + await db.query('ROLLBACK'); + print('Erreur lors de la suppression du point de vente: $e'); + rethrow; + } +} + +// SOLUTION 2: Méthode alternative avec suppression douce (soft delete) +Future deletePointDeVenteSoft(int id) async { + final db = await database; + + try { + // Ajouter une colonne 'actif' si elle n'existe pas déjà + // Cette solution nécessite d'ajouter une colonne, mais c'est moins invasif + + // Pour l'instant, on peut utiliser une astuce en renommant le point de vente + final timestamp = DateTime.now().millisecondsSinceEpoch; + final result = await db.query( + 'UPDATE points_de_vente SET nom = CONCAT(nom, " (Supprimé ", ?, ")") WHERE id = ?', + [timestamp, id] + ); + + return result.affectedRows!; + } catch (e) { + print('Erreur lors de la suppression douce: $e'); + rethrow; + } +} + +// SOLUTION 3: Vérification avec message d'erreur personnalisé +Future> checkCanDeletePointDeVente(int id) async { + final db = await database; + + try { + // Vérifier les produits + final produitsResult = await db.query( + 'SELECT COUNT(*) as count FROM products WHERE point_de_vente_id = ?', + [id] + ); + final nombreProduits = produitsResult.first['count'] as int; + + // Vérifier les utilisateurs + final usersResult = await db.query( + 'SELECT COUNT(*) as count FROM users WHERE point_de_vente_id = ?', + [id] + ); + final nombreUsers = usersResult.first['count'] as int; + + // Vérifier les demandes de transfert + final transfertsResult = await db.query(''' + SELECT COUNT(*) as count FROM demandes_transfert + WHERE point_de_vente_source_id = ? OR point_de_vente_destination_id = ? + ''', [id, id]); + final nombreTransferts = transfertsResult.first['count'] as int; + + if (nombreProduits > 0 || nombreUsers > 0 || nombreTransferts > 0) { + return { + 'canDelete': false, + 'reasons': [ + if (nombreProduits > 0) '$nombreProduits produit(s) associé(s)', + if (nombreUsers > 0) '$nombreUsers utilisateur(s) associé(s)', + if (nombreTransferts > 0) '$nombreTransferts demande(s) de transfert associée(s)', + ], + 'suggestions': [ + if (nombreProduits > 0) 'Transférez d\'abord les produits vers un autre point de vente', + if (nombreUsers > 0) 'Réassignez d\'abord les utilisateurs à un autre point de vente', + if (nombreTransferts > 0) 'Les demandes de transfert resteront pour l\'historique', + ] + }; + } + + return { + 'canDelete': true, + 'reasons': [], + 'suggestions': [] + }; + } catch (e) { + return { + 'canDelete': false, + 'reasons': ['Erreur lors de la vérification: $e'], + 'suggestions': [] + }; + } +} + +// SOLUTION 4: Suppression avec transfert automatique vers un point de vente spécifique +Future deletePointDeVenteWithTransfer(int idToDelete, int targetPointDeVenteId) async { + final db = await database; + + try { + await db.query('START TRANSACTION'); + + // 1. Transférer tous les produits + await db.query( + 'UPDATE products SET point_de_vente_id = ? WHERE point_de_vente_id = ?', + [targetPointDeVenteId, idToDelete] + ); + + // 2. Transférer tous les utilisateurs (si applicable) + await db.query( + 'UPDATE users SET point_de_vente_id = ? WHERE point_de_vente_id = ?', + [targetPointDeVenteId, idToDelete] + ); + + // 3. Supprimer le point de vente + final result = await db.query( + 'DELETE FROM points_de_vente WHERE id = ?', + [idToDelete] + ); + + await db.query('COMMIT'); return result.affectedRows!; + + } catch (e) { + await db.query('ROLLBACK'); + rethrow; } +} +// SOLUTION 5: Méthode pour obtenir les points de vente de destination possibles +Future>> getPointsDeVenteForTransfer(int excludeId) async { + final db = await database; + + try { + final result = await db.query( + 'SELECT * FROM points_de_vente WHERE id != ? ORDER BY nom ASC', + [excludeId] + ); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération points de vente pour transfert: $e'); + return []; + } +} Future?> getPointDeVenteById(int id) async { final db = await database; final result = await db.query('SELECT * FROM points_de_vente WHERE id = ?', [id]); @@ -2208,4 +2490,763 @@ Future verifyCurrentUserPassword(String password) async { } } +// Dans AppDatabase +Future createDemandeTransfert({ + required int produitId, + required int pointDeVenteSourceId, + required int pointDeVenteDestinationId, + required int demandeurId, + int quantite = 1, + String? notes, +}) async { + final db = await database; + + try { + final result = await db.query(''' + INSERT INTO demandes_transfert ( + produit_id, + point_de_vente_source_id, + point_de_vente_destination_id, + demandeur_id, + quantite, + statut, + date_demande, + notes + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', [ + produitId, + pointDeVenteSourceId, + pointDeVenteDestinationId, + demandeurId, + quantite, + 'en_attente', // Statut initial + DateTime.now().toUtc(), + notes, + ]); + + return result.insertId!; + } catch (e) { + print('Erreur création demande transfert: $e'); + rethrow; + } +} +Future>> getDemandesTransfertEnAttente() async { + final db = await database; + try { + final result = await db.query(''' + SELECT dt.*, + p.name as produit_nom, + p.reference as produit_reference, + p.stock as stock_source, -- AJOUT : récupérer le stock du produit + pv_source.nom as point_vente_source, + pv_dest.nom as point_vente_destination, + u.name as demandeur_nom + FROM demandes_transfert dt + JOIN products p ON dt.produit_id = p.id + JOIN points_de_vente pv_source ON dt.point_de_vente_source_id = pv_source.id + JOIN points_de_vente pv_dest ON dt.point_de_vente_destination_id = pv_dest.id + JOIN users u ON dt.demandeur_id = u.id + WHERE dt.statut = 'en_attente' + ORDER BY dt.date_demande DESC + '''); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération demandes transfert: $e'); + return []; + } +} + +Future validerTransfert(int demandeId, int validateurId) async { + final db = await database; + + try { + await db.query('START TRANSACTION'); + + // 1. Récupérer les infos de la demande + final demande = await db.query( + 'SELECT * FROM demandes_transfert WHERE id = ? FOR UPDATE', + [demandeId] + ); + + if (demande.isEmpty) { + throw Exception('Demande de transfert introuvable'); + } + + final fields = demande.first.fields; + final produitId = fields['produit_id'] as int; + final quantite = fields['quantite'] as int; + final sourceId = fields['point_de_vente_source_id'] as int; + final destinationId = fields['point_de_vente_destination_id'] as int; + + // 2. Vérifier le stock source + final stockSource = await db.query( + 'SELECT stock FROM products WHERE id = ? AND point_de_vente_id = ? FOR UPDATE', + [produitId, sourceId] + ); + + if (stockSource.isEmpty) { + throw Exception('Produit introuvable dans le point de vente source'); + } + + final stockDisponible = stockSource.first['stock'] as int; + if (stockDisponible < quantite) { + throw Exception('Stock insuffisant dans le point de vente source'); + } + + // 3. Mettre à jour le stock source + await db.query( + 'UPDATE products SET stock = stock - ? WHERE id = ? AND point_de_vente_id = ?', + [quantite, produitId, sourceId] + ); + + // 4. Vérifier si le produit existe déjà dans le point de vente destination + final produitDestination = await db.query( + 'SELECT id, stock FROM products WHERE id = ? AND point_de_vente_id = ?', + [produitId, destinationId] + ); + + if (produitDestination.isNotEmpty) { + // Mettre à jour le stock existant + await db.query( + 'UPDATE products SET stock = stock + ? WHERE id = ? AND point_de_vente_id = ?', + [quantite, produitId, destinationId] + ); + } else { + // Créer une copie du produit dans le nouveau point de vente + final produit = await db.query( + 'SELECT * FROM products WHERE id = ?', + [produitId] + ); + + if (produit.isEmpty) { + throw Exception('Produit introuvable'); + } + + final produitFields = produit.first.fields; + await db.query(''' + INSERT INTO products ( + name, price, image, category, stock, description, + qrCode, reference, point_de_vente_id, marque, + ram, memoire_interne, imei + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ''', [ + produitFields['name'], + produitFields['price'], + produitFields['image'], + produitFields['category'], + quantite, // Nouveau stock + produitFields['description'], + produitFields['qrCode'], + produitFields['reference'], + destinationId, + produitFields['marque'], + produitFields['ram'], + produitFields['memoire_interne'], + null, // IMEI doit être unique donc on ne le copie pas + ]); + } + + // 5. Mettre à jour le statut de la demande + await db.query(''' + UPDATE demandes_transfert + SET + statut = 'validee', + validateur_id = ?, + date_validation = ? + WHERE id = ? + ''', [ + validateurId, + DateTime.now().toUtc(), + demandeId + ]); + + await db.query('COMMIT'); + return 1; + } catch (e) { + await db.query('ROLLBACK'); + print('Erreur validation transfert: $e'); + rethrow; + } +} +// Ajoutez ces méthodes dans votre classe AppDatabase + +// 1. Méthode pour récupérer les demandes de transfert validées +Future>> getDemandesTransfertValidees() async { + final db = await database; + try { + final result = await db.query(''' + SELECT dt.*, + p.name as produit_nom, + p.reference as produit_reference, + p.stock as stock_source, + pv_source.nom as point_vente_source, + pv_dest.nom as point_vente_destination, + u_demandeur.name as demandeur_nom, + u_validateur.name as validateur_nom, + u_validateur.lastname as validateur_lastname + FROM demandes_transfert dt + JOIN products p ON dt.produit_id = p.id + JOIN points_de_vente pv_source ON dt.point_de_vente_source_id = pv_source.id + JOIN points_de_vente pv_dest ON dt.point_de_vente_destination_id = pv_dest.id + JOIN users u_demandeur ON dt.demandeur_id = u_demandeur.id + LEFT JOIN users u_validateur ON dt.validateur_id = u_validateur.id + WHERE dt.statut = 'validee' + ORDER BY dt.date_validation DESC + '''); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération demandes transfert validées: $e'); + return []; + } +} + +// 2. Méthode pour récupérer toutes les demandes de transfert +Future>> getToutesDemandesTransfert() async { + final db = await database; + try { + final result = await db.query(''' + SELECT dt.*, + p.name as produit_nom, + p.reference as produit_reference, + p.stock as stock_source, + pv_source.nom as point_vente_source, + pv_dest.nom as point_vente_destination, + u_demandeur.name as demandeur_nom, + u_validateur.name as validateur_nom, + u_validateur.lastname as validateur_lastname + FROM demandes_transfert dt + JOIN products p ON dt.produit_id = p.id + JOIN points_de_vente pv_source ON dt.point_de_vente_source_id = pv_source.id + JOIN points_de_vente pv_dest ON dt.point_de_vente_destination_id = pv_dest.id + JOIN users u_demandeur ON dt.demandeur_id = u_demandeur.id + LEFT JOIN users u_validateur ON dt.validateur_id = u_validateur.id + ORDER BY + CASE dt.statut + WHEN 'en_attente' THEN 1 + WHEN 'validee' THEN 2 + WHEN 'refusee' THEN 3 + END, + dt.date_demande DESC + '''); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération toutes demandes transfert: $e'); + return []; + } +} + +// 3. Méthode pour rejeter une demande de transfert +Future rejeterTransfert(int demandeId, int validateurId, String motif) async { + final db = await database; + + try { + await db.query('START TRANSACTION'); + + // Vérifier que la demande existe et est en attente + final demande = await db.query( + 'SELECT * FROM demandes_transfert WHERE id = ? AND statut = ? FOR UPDATE', + [demandeId, 'en_attente'] + ); + + if (demande.isEmpty) { + throw Exception('Demande de transfert introuvable ou déjà traitée'); + } + + // Mettre à jour le statut de la demande + final result = await db.query(''' + UPDATE demandes_transfert + SET + statut = 'refusee', + validateur_id = ?, + date_validation = ?, + notes = CONCAT(COALESCE(notes, ''), + CASE WHEN notes IS NULL OR notes = '' THEN '' ELSE '\n--- REJET ---\n' END, + 'Rejetée le ', ?, ' par validateur ID ', ?, ': ', ?) + WHERE id = ? + ''', [ + validateurId, + DateTime.now().toUtc(), + DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now()), + validateurId, + motif, + demandeId + ]); + + await db.query('COMMIT'); + + print('Demande de transfert $demandeId rejetée par l\'utilisateur $validateurId'); + print('Motif: $motif'); + + return result.affectedRows!; + } catch (e) { + await db.query('ROLLBACK'); + print('Erreur rejet transfert: $e'); + rethrow; + } +} + +// 4. Méthode supplémentaire : récupérer les demandes de transfert refusées +Future>> getDemandesTransfertRefusees() async { + final db = await database; + try { + final result = await db.query(''' + SELECT dt.*, + p.name as produit_nom, + p.reference as produit_reference, + p.stock as stock_source, + pv_source.nom as point_vente_source, + pv_dest.nom as point_vente_destination, + u_demandeur.name as demandeur_nom, + u_validateur.name as validateur_nom, + u_validateur.lastname as validateur_lastname + FROM demandes_transfert dt + JOIN products p ON dt.produit_id = p.id + JOIN points_de_vente pv_source ON dt.point_de_vente_source_id = pv_source.id + JOIN points_de_vente pv_dest ON dt.point_de_vente_destination_id = pv_dest.id + JOIN users u_demandeur ON dt.demandeur_id = u_demandeur.id + LEFT JOIN users u_validateur ON dt.validateur_id = u_validateur.id + WHERE dt.statut = 'refusee' + ORDER BY dt.date_validation DESC + '''); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération demandes transfert refusées: $e'); + return []; + } +} + +// 5. Méthode pour récupérer les demandes par statut spécifique +Future>> getDemandesTransfertParStatut(String statut) async { + final db = await database; + try { + final result = await db.query(''' + SELECT dt.*, + p.name as produit_nom, + p.reference as produit_reference, + p.stock as stock_source, + pv_source.nom as point_vente_source, + pv_dest.nom as point_vente_destination, + u_demandeur.name as demandeur_nom, + u_validateur.name as validateur_nom, + u_validateur.lastname as validateur_lastname + FROM demandes_transfert dt + JOIN products p ON dt.produit_id = p.id + JOIN points_de_vente pv_source ON dt.point_de_vente_source_id = pv_source.id + JOIN points_de_vente pv_dest ON dt.point_de_vente_destination_id = pv_dest.id + JOIN users u_demandeur ON dt.demandeur_id = u_demandeur.id + LEFT JOIN users u_validateur ON dt.validateur_id = u_validateur.id + WHERE dt.statut = ? + ORDER BY + CASE + WHEN dt.statut = 'en_attente' THEN dt.date_demande + ELSE dt.date_validation + END DESC + ''', [statut]); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération demandes transfert par statut: $e'); + return []; + } +} + +// 6. Méthode pour récupérer les statistiques des transferts +Future> getStatistiquesTransferts() async { + final db = await database; + + try { + // Statistiques générales + final statsGenerales = await db.query(''' + SELECT + COUNT(*) as total_demandes, + SUM(CASE WHEN statut = 'en_attente' THEN 1 ELSE 0 END) as en_attente, + SUM(CASE WHEN statut = 'validee' THEN 1 ELSE 0 END) as validees, + SUM(CASE WHEN statut = 'refusee' THEN 1 ELSE 0 END) as refusees, + SUM(CASE WHEN statut = 'validee' THEN quantite ELSE 0 END) as quantite_totale_transferee + FROM demandes_transfert + '''); + + // Top des produits les plus transférés + final topProduits = await db.query(''' + SELECT + p.name as produit_nom, + p.category as categorie, + COUNT(*) as nombre_demandes, + SUM(dt.quantite) as quantite_totale, + SUM(CASE WHEN dt.statut = 'validee' THEN dt.quantite ELSE 0 END) as quantite_validee + FROM demandes_transfert dt + JOIN products p ON dt.produit_id = p.id + GROUP BY dt.produit_id, p.name, p.category + ORDER BY quantite_totale DESC + LIMIT 10 + '''); + + // Points de vente les plus actifs + final topPointsVente = await db.query(''' + SELECT + pv.nom as point_vente, + COUNT(dt_source.id) as demandes_sortantes, + COUNT(dt_dest.id) as demandes_entrantes, + (COUNT(dt_source.id) + COUNT(dt_dest.id)) as total_activite + FROM points_de_vente pv + LEFT JOIN demandes_transfert dt_source ON pv.id = dt_source.point_de_vente_source_id + LEFT JOIN demandes_transfert dt_dest ON pv.id = dt_dest.point_de_vente_destination_id + WHERE (dt_source.id IS NOT NULL OR dt_dest.id IS NOT NULL) + GROUP BY pv.id, pv.nom + ORDER BY total_activite DESC + LIMIT 10 + '''); + + return { + 'stats_generales': statsGenerales.first.fields, + 'top_produits': topProduits.map((row) => row.fields).toList(), + 'top_points_vente': topPointsVente.map((row) => row.fields).toList(), + }; + } catch (e) { + print('Erreur récupération statistiques transferts: $e'); + return { + 'stats_generales': { + 'total_demandes': 0, + 'en_attente': 0, + 'validees': 0, + 'refusees': 0, + 'quantite_totale_transferee': 0 + }, + 'top_produits': [], + 'top_points_vente': [], + }; + } +} + +// 7. Méthode pour récupérer l'historique des transferts d'un produit +Future>> getHistoriqueTransfertsProduit(int produitId) async { + final db = await database; + try { + final result = await db.query(''' + SELECT dt.*, + pv_source.nom as point_vente_source, + pv_dest.nom as point_vente_destination, + u_demandeur.name as demandeur_nom, + u_validateur.name as validateur_nom + FROM demandes_transfert dt + JOIN points_de_vente pv_source ON dt.point_de_vente_source_id = pv_source.id + JOIN points_de_vente pv_dest ON dt.point_de_vente_destination_id = pv_dest.id + JOIN users u_demandeur ON dt.demandeur_id = u_demandeur.id + LEFT JOIN users u_validateur ON dt.validateur_id = u_validateur.id + WHERE dt.produit_id = ? + ORDER BY dt.date_demande DESC + ''', [produitId]); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération historique transferts produit: $e'); + return []; + } +} + +// 8. Méthode pour annuler une demande de transfert (si en attente) +Future annulerDemandeTransfert(int demandeId, int utilisateurId) async { + final db = await database; + + try { + // Vérifier que la demande existe et est en attente + final demande = await db.query( + 'SELECT * FROM demandes_transfert WHERE id = ? AND statut = ? AND demandeur_id = ?', + [demandeId, 'en_attente', utilisateurId] + ); + + if (demande.isEmpty) { + throw Exception('Demande introuvable, déjà traitée, ou vous n\'êtes pas autorisé à l\'annuler'); + } + + // Supprimer la demande (ou la marquer comme annulée si vous préférez garder l'historique) + final result = await db.query( + 'DELETE FROM demandes_transfert WHERE id = ? AND statut = ? AND demandeur_id = ?', + [demandeId, 'en_attente', utilisateurId] + ); + + return result.affectedRows!; + } catch (e) { + print('Erreur annulation demande transfert: $e'); + rethrow; + } +} + +// --- MÉTHODES POUR SORTIES STOCK PERSONNELLES --- + +Future createSortieStockPersonnelle({ + required int produitId, + required int adminId, + required int quantite, + required String motif, + int? pointDeVenteId, + String? notes, +}) async { + final db = await database; + + try { + await db.query('START TRANSACTION'); + + // 1. Vérifier que le produit existe et a assez de stock + final produit = await getProductById(produitId); + if (produit == null) { + throw Exception('Produit introuvable'); + } + + if (produit.stock != null && produit.stock! < quantite) { + throw Exception('Stock insuffisant (disponible: ${produit.stock}, demandé: $quantite)'); + } + + // 2. Créer la demande de sortie + final result = await db.query(''' + INSERT INTO sorties_stock_personnelles ( + produit_id, + admin_id, + quantite, + motif, + date_sortie, + point_de_vente_id, + notes, + statut + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ''', [ + produitId, + adminId, + quantite, + motif, + DateTime.now().toUtc(), + pointDeVenteId, + notes, + 'en_attente', // Par défaut en attente d'approbation + ]); + + await db.query('COMMIT'); + return result.insertId!; + } catch (e) { + await db.query('ROLLBACK'); + print('Erreur création sortie personnelle: $e'); + rethrow; + } +} + +Future approuverSortiePersonnelle(int sortieId, int approbateurId) async { + final db = await database; + + try { + await db.query('START TRANSACTION'); + + // 1. Récupérer les détails de la sortie + final sortie = await db.query( + 'SELECT * FROM sorties_stock_personnelles WHERE id = ? AND statut = ?', + [sortieId, 'en_attente'] + ); + + if (sortie.isEmpty) { + throw Exception('Sortie introuvable ou déjà traitée'); + } + + final fields = sortie.first.fields; + final produitId = fields['produit_id'] as int; + final quantite = fields['quantite'] as int; + + // 2. Vérifier le stock actuel + final produit = await getProductById(produitId); + if (produit == null) { + throw Exception('Produit introuvable'); + } + + if (produit.stock != null && produit.stock! < quantite) { + throw Exception('Stock insuffisant pour approuver cette sortie'); + } + + // 3. Décrémenter le stock + await db.query( + 'UPDATE products SET stock = stock - ? WHERE id = ?', + [quantite, produitId] + ); + + // 4. Marquer la sortie comme approuvée + await db.query(''' + UPDATE sorties_stock_personnelles + SET + statut = 'approuvee', + approbateur_id = ?, + date_approbation = ? + WHERE id = ? + ''', [ + approbateurId, + DateTime.now().toUtc(), + sortieId + ]); + + await db.query('COMMIT'); + return 1; + } catch (e) { + await db.query('ROLLBACK'); + print('Erreur approbation sortie: $e'); + rethrow; + } +} + +Future refuserSortiePersonnelle(int sortieId, int approbateurId, String motifRefus) async { + final db = await database; + + try { + final result = await db.query(''' + UPDATE sorties_stock_personnelles + SET + statut = 'refusee', + approbateur_id = ?, + date_approbation = ?, + notes = CONCAT(COALESCE(notes, ''), '\n--- REFUS ---\n', ?) + WHERE id = ? AND statut = 'en_attente' + ''', [ + approbateurId, + DateTime.now().toUtc(), + motifRefus, + sortieId + ]); + + return result.affectedRows!; + } catch (e) { + print('Erreur refus sortie: $e'); + rethrow; + } +} + +Future>> getSortiesPersonnellesEnAttente() async { + final db = await database; + + try { + final result = await db.query(''' + SELECT sp.*, + p.name as produit_nom, + p.reference as produit_reference, + p.stock as stock_actuel, + u_admin.name as admin_nom, + u_admin.lastname as admin_nom_famille, + pv.nom as point_vente_nom + FROM sorties_stock_personnelles sp + JOIN products p ON sp.produit_id = p.id + JOIN users u_admin ON sp.admin_id = u_admin.id + LEFT JOIN points_de_vente pv ON sp.point_de_vente_id = pv.id + WHERE sp.statut = 'en_attente' + ORDER BY sp.date_sortie DESC + '''); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération sorties en attente: $e'); + return []; + } +} + +Future>> getHistoriqueSortiesPersonnelles({ + int? adminId, + String? statut, + int limit = 50, +}) async { + final db = await database; + + try { + String whereClause = ''; + List params = []; + + if (adminId != null) { + whereClause = 'WHERE sp.admin_id = ?'; + params.add(adminId); + } + + if (statut != null) { + whereClause += (whereClause.isEmpty ? 'WHERE' : ' AND') + ' sp.statut = ?'; + params.add(statut); + } + + final result = await db.query(''' + SELECT sp.*, + p.name as produit_nom, + p.reference as produit_reference, + u_admin.name as admin_nom, + u_admin.lastname as admin_nom_famille, + u_approb.name as approbateur_nom, + u_approb.lastname as approbateur_nom_famille, + pv.nom as point_vente_nom + FROM sorties_stock_personnelles sp + JOIN products p ON sp.produit_id = p.id + JOIN users u_admin ON sp.admin_id = u_admin.id + LEFT JOIN users u_approb ON sp.approbateur_id = u_approb.id + LEFT JOIN points_de_vente pv ON sp.point_de_vente_id = pv.id + $whereClause + ORDER BY sp.date_sortie DESC + LIMIT ? + ''', [...params, limit]); + + return result.map((row) => row.fields).toList(); + } catch (e) { + print('Erreur récupération historique sorties: $e'); + return []; + } +} + +Future> getStatistiquesSortiesPersonnelles() async { + final db = await database; + + try { + // Total des sorties par statut + final statsStatut = await db.query(''' + SELECT + statut, + COUNT(*) as nombre, + SUM(quantite) as quantite_totale + FROM sorties_stock_personnelles + GROUP BY statut + '''); + + // Sorties par admin + final statsAdmin = await db.query(''' + SELECT + u.name as admin_nom, + u.lastname as admin_nom_famille, + COUNT(*) as nombre_sorties, + SUM(sp.quantite) as quantite_totale + FROM sorties_stock_personnelles sp + JOIN users u ON sp.admin_id = u.id + WHERE sp.statut = 'approuvee' + GROUP BY u.id, u.name, u.lastname + ORDER BY quantite_totale DESC + LIMIT 10 + '''); + + // Produits les plus sortis + final statsProduits = await db.query(''' + SELECT + p.name as produit_nom, + p.reference as produit_reference, + SUM(sp.quantite) as quantite_sortie + FROM sorties_stock_personnelles sp + JOIN products p ON sp.produit_id = p.id + WHERE sp.statut = 'approuvee' + GROUP BY p.id, p.name, p.reference + ORDER BY quantite_sortie DESC + LIMIT 10 + '''); + + return { + 'stats_statut': statsStatut.map((row) => row.fields).toList(), + 'stats_admin': statsAdmin.map((row) => row.fields).toList(), + 'stats_produits': statsProduits.map((row) => row.fields).toList(), + }; + } catch (e) { + print('Erreur statistiques sorties: $e'); + return { + 'stats_statut': [], + 'stats_admin': [], + 'stats_produits': [], + }; + } +} } \ No newline at end of file diff --git a/lib/Views/DemandeTransfert.dart b/lib/Views/DemandeTransfert.dart new file mode 100644 index 0000000..c3a8a7d --- /dev/null +++ b/lib/Views/DemandeTransfert.dart @@ -0,0 +1,847 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:youmazgestion/Components/appDrawer.dart'; +import 'package:youmazgestion/Services/stock_managementDatabase.dart'; +import 'package:youmazgestion/controller/userController.dart'; + +class GestionTransfertsPage extends StatefulWidget { + const GestionTransfertsPage({super.key}); + + @override + _GestionTransfertsPageState createState() => _GestionTransfertsPageState(); +} + +class _GestionTransfertsPageState extends State with TickerProviderStateMixin { + final AppDatabase _appDatabase = AppDatabase.instance; + final UserController _userController = Get.find(); + + List> _demandes = []; + List> _filteredDemandes = []; + bool _isLoading = false; + String _selectedStatut = 'en_attente'; + String _searchQuery = ''; + + late TabController _tabController; + final TextEditingController _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 3, vsync: this); + _loadDemandes(); + _searchController.addListener(_filterDemandes); + } + + @override + void dispose() { + _tabController.dispose(); + _searchController.dispose(); + super.dispose(); + } + + Future _loadDemandes() async { + setState(() => _isLoading = true); + try { + List> demandes; + + switch (_selectedStatut) { + case 'en_attente': + demandes = await _appDatabase.getDemandesTransfertEnAttente(); + break; + case 'validees': + demandes = await _appDatabase.getDemandesTransfertValidees(); + break; + case 'toutes': + demandes = await _appDatabase.getToutesDemandesTransfert(); + break; + default: + demandes = await _appDatabase.getDemandesTransfertEnAttente(); + } + + setState(() { + _demandes = demandes; + _filteredDemandes = demandes; + }); + _filterDemandes(); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible de charger les demandes: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + setState(() => _isLoading = false); + } + } + + void _filterDemandes() { + final query = _searchController.text.toLowerCase(); + setState(() { + _filteredDemandes = _demandes.where((demande) { + final produitNom = (demande['produit_nom'] ?? '').toString().toLowerCase(); + final produitRef = (demande['produit_reference'] ?? '').toString().toLowerCase(); + final demandeurNom = (demande['demandeur_nom'] ?? '').toString().toLowerCase(); + final pointVenteSource = (demande['point_vente_source'] ?? '').toString().toLowerCase(); + final pointVenteDestination = (demande['point_vente_destination'] ?? '').toString().toLowerCase(); + + return produitNom.contains(query) || + produitRef.contains(query) || + demandeurNom.contains(query) || + pointVenteSource.contains(query) || + pointVenteDestination.contains(query); + }).toList(); + }); + } + + Future _validerDemande(int demandeId, Map demande) async { + // Vérifier seulement si le produit est en rupture de stock (stock = 0) + final stockDisponible = demande['stock_source'] as int? ?? 0; + + if (stockDisponible == 0) { + await _showRuptureStockDialog(demande); + return; + } + + final confirmation = await _showConfirmationDialog(demande); + if (!confirmation) return; + + setState(() => _isLoading = true); + try { + await _appDatabase.validerTransfert(demandeId, _userController.userId); + + Get.snackbar( + 'Succès', + 'Transfert validé avec succès', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 3), + icon: const Icon(Icons.check_circle, color: Colors.white), + ); + + await _loadDemandes(); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible de valider le transfert: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 4), + icon: const Icon(Icons.error, color: Colors.white), + ); + } finally { + setState(() => _isLoading = false); + } + } + + Future _rejeterDemande(int demandeId, Map demande) async { + final motif = await _showRejectionDialog(); + if (motif == null) return; + + setState(() => _isLoading = true); + try { + await _appDatabase.rejeterTransfert(demandeId, _userController.userId, motif); + + Get.snackbar( + 'Demande rejetée', + 'La demande de transfert a été rejetée', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange, + colorText: Colors.white, + duration: const Duration(seconds: 3), + ); + + await _loadDemandes(); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible de rejeter la demande: $e', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + setState(() => _isLoading = false); + } + } + + Future _showConfirmationDialog(Map demande) async { + final stockDisponible = demande['stock_source'] as int? ?? 0; + final quantiteDemandee = demande['quantite'] as int; + final stockInsuffisant = stockDisponible < quantiteDemandee && stockDisponible > 0; + + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.swap_horiz, color: Colors.blue.shade700), + const SizedBox(width: 8), + const Text('Confirmer le transfert'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Êtes-vous sûr de vouloir valider ce transfert ?'), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Produit: ${demande['produit_nom']}', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + Text('Référence: ${demande['produit_reference']}'), + Text('Quantité: ${demande['quantite']}'), + Text('De: ${demande['point_vente_source']}'), + Text('Vers: ${demande['point_vente_destination']}'), + Text( + 'Stock disponible: $stockDisponible', + style: TextStyle( + color: stockInsuffisant ? Colors.orange.shade700 : Colors.green.shade700, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + if (stockInsuffisant) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade200), + ), + child: Row( + children: [ + Icon(Icons.info, size: 16, color: Colors.orange.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Le stock sera insuffisant après ce transfert', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Valider'), + ), + ], + ), + ) ?? false; + } + + Future _showRuptureStockDialog(Map demande) async { + await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.warning, color: Colors.red.shade700), + const SizedBox(width: 8), + const Text('Rupture de stock'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Impossible d\'effectuer ce transfert car le produit est en rupture de stock.', + style: TextStyle(color: Colors.red.shade700), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Produit: ${demande['produit_nom']}'), + Text('Quantité demandée: ${demande['quantite']}'), + Text( + 'Stock disponible: 0', + style: TextStyle( + color: Colors.red.shade700, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.pop(context), + child: const Text('Compris'), + ), + ], + ), + ); + } + + Future _showRejectionDialog() async { + final TextEditingController motifController = TextEditingController(); + + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Rejeter la demande'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Veuillez indiquer le motif du rejet :'), + const SizedBox(height: 12), + TextField( + controller: motifController, + decoration: const InputDecoration( + hintText: 'Motif du rejet', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + if (motifController.text.trim().isNotEmpty) { + Navigator.pop(context, motifController.text.trim()); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange, + foregroundColor: Colors.white, + ), + child: const Text('Rejeter'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 600; + + return Scaffold( + appBar: AppBar( + title: const Text('Gestion des transferts'), + backgroundColor: Colors.blue.shade700, + foregroundColor: Colors.white, + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadDemandes, + tooltip: 'Actualiser', + ), + ], + bottom: TabBar( + controller: _tabController, + onTap: (index) { + setState(() { + switch (index) { + case 0: + _selectedStatut = 'en_attente'; + break; + case 1: + _selectedStatut = 'validees'; + break; + case 2: + _selectedStatut = 'toutes'; + break; + } + }); + _loadDemandes(); + }, + labelColor: Colors.white, + unselectedLabelColor: Colors.white70, + indicatorColor: Colors.white, + tabs: const [ + Tab( + icon: Icon(Icons.pending_actions), + text: 'En attente', + ), + Tab( + icon: Icon(Icons.check_circle), + text: 'Validées', + ), + Tab( + icon: Icon(Icons.list), + text: 'Toutes', + ), + ], + ), + ), + drawer: CustomDrawer(), + body: Column( + children: [ + // Barre de recherche + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par produit, référence, demandeur...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _filterDemandes(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.grey.shade100, + ), + ), + ), + + // Compteur de résultats + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Text( + '${_filteredDemandes.length} demande(s)', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + const Spacer(), + if (_selectedStatut == 'en_attente' && _filteredDemandes.isNotEmpty) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + 'Action requise', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + + // Liste des demandes + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _filteredDemandes.isEmpty + ? _buildEmptyState() + : TabBarView( + controller: _tabController, + children: [ + _buildDemandesEnAttente(), + _buildDemandesValidees(), + _buildToutesLesDemandes(), + ], + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + String message; + IconData icon; + + switch (_selectedStatut) { + case 'en_attente': + message = 'Aucune demande en attente'; + icon = Icons.inbox; + break; + case 'validees': + message = 'Aucune demande validée'; + icon = Icons.check_circle_outline; + break; + default: + message = 'Aucune demande trouvée'; + icon = Icons.search_off; + } + + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + icon, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + message, + style: TextStyle( + fontSize: 18, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + if (_searchController.text.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Aucun résultat pour "${_searchController.text}"', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade500, + ), + ), + ], + ], + ), + ); + } + + Widget _buildDemandesEnAttente() { + return _buildDemandesList(showActions: true); + } + + Widget _buildDemandesValidees() { + return _buildDemandesList(showActions: false); + } + + Widget _buildToutesLesDemandes() { + return _buildDemandesList(showActions: _selectedStatut == 'en_attente'); + } + + Widget _buildDemandesList({required bool showActions}) { + return RefreshIndicator( + onRefresh: _loadDemandes, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _filteredDemandes.length, + itemBuilder: (context, index) { + final demande = _filteredDemandes[index]; + return _buildDemandeCard(demande, showActions); + }, + ), + ); + } + + Widget _buildDemandeCard(Map demande, bool showActions) { + final isMobile = MediaQuery.of(context).size.width < 600; + final statut = demande['statut'] as String? ?? 'en_attente'; + final stockDisponible = demande['stock_source'] as int? ?? 0; + final quantiteDemandee = demande['quantite'] as int; + final enRuptureStock = stockDisponible == 0; + + Color statutColor; + IconData statutIcon; + String statutText; + + switch (statut) { + case 'validee': + statutColor = Colors.green; + statutIcon = Icons.check_circle; + statutText = 'Validée'; + break; + case 'rejetee': + statutColor = Colors.red; + statutIcon = Icons.cancel; + statutText = 'Rejetée'; + break; + default: + statutColor = Colors.orange; + statutIcon = Icons.pending; + statutText = 'En attente'; + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: enRuptureStock && statut == 'en_attente' + ? BorderSide(color: Colors.red.shade300, width: 1.5) + : BorderSide.none, + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avec produit et statut + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + demande['produit_nom'] ?? 'Produit inconnu', + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + const SizedBox(height: 4), + Text( + 'Réf: ${demande['produit_reference'] ?? 'N/A'}', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statutColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: statutColor.withOpacity(0.3)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(statutIcon, size: 16, color: statutColor), + const SizedBox(width: 4), + Text( + statutText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: statutColor, + ), + ), + ], + ), + ), + ], + ), + + const SizedBox(height: 12), + + // Informations de transfert + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + children: [ + Row( + children: [ + Icon(Icons.store, size: 16, color: Colors.blue.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + '${demande['point_vente_source'] ?? 'N/A'}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Icon(Icons.arrow_forward, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + '${demande['point_vente_destination'] ?? 'N/A'}', + style: const TextStyle(fontWeight: FontWeight.w500), + textAlign: TextAlign.end, + ), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Icon(Icons.inventory_2, size: 16, color: Colors.green.shade600), + const SizedBox(width: 8), + Text('Quantité: $quantiteDemandee'), + const Spacer(), + Text( + 'Stock source: $stockDisponible', + style: TextStyle( + color: enRuptureStock + ? Colors.red.shade600 + : stockDisponible < quantiteDemandee + ? Colors.orange.shade600 + : Colors.green.shade600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 12), + + // Informations de la demande + Row( + children: [ + Icon(Icons.person, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Demandé par: ${demande['demandeur_nom'] ?? 'N/A'}', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + Row( + children: [ + Icon(Icons.access_time, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 8), + Text( + DateFormat('dd/MM/yyyy à HH:mm').format( + (demande['date_demande'] as DateTime).toLocal() + ), + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade700, + ), + ), + ], + ), + + // Alerte rupture de stock + if (enRuptureStock && statut == 'en_attente') ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + Icon(Icons.warning, size: 16, color: Colors.red.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Produit en rupture de stock', + style: TextStyle( + fontSize: 12, + color: Colors.red.shade700, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + + // Actions (seulement pour les demandes en attente) + if (showActions && statut == 'en_attente') ...[ + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () => _rejeterDemande( + demande['id'] as int, + demande, + ), + icon: const Icon(Icons.close, size: 18), + label: Text(isMobile ? 'Rejeter' : 'Rejeter'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red.shade600, + side: BorderSide(color: Colors.red.shade300), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: !enRuptureStock + ? () => _validerDemande( + demande['id'] as int, + demande, + ) + : null, + icon: const Icon(Icons.check, size: 18), + label: Text(isMobile ? 'Valider' : 'Valider'), + style: ElevatedButton.styleFrom( + backgroundColor: !enRuptureStock + ? Colors.green.shade600 + : Colors.grey.shade400, + foregroundColor: Colors.white, + ), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/Views/HandleProduct.dart b/lib/Views/HandleProduct.dart index 7f814a0..f0d6dd5 100644 --- a/lib/Views/HandleProduct.dart +++ b/lib/Views/HandleProduct.dart @@ -53,7 +53,9 @@ class _ProductManagementPageState extends State { 'Laptop', 'Non catégorisé' ]; - +bool _isUserSuperAdmin() { + return _userController.role == 'Super Admin'; +} // Variables pour l'import Excel (conservées du code original) bool _isImporting = false; double _importProgress = 0.0; @@ -2569,11 +2571,13 @@ class _ProductManagementPageState extends State { icon: const Icon(Icons.qr_code_2, color: Colors.blue), tooltip: 'Voir QR Code', ), + if(_isUserSuperAdmin()) IconButton( onPressed: () => _editProduct(product), icon: const Icon(Icons.edit, color: Colors.orange), tooltip: 'Modifier', ), + if(_isUserSuperAdmin()) IconButton( onPressed: () => _deleteProduct(product), icon: const Icon(Icons.delete, color: Colors.red), diff --git a/lib/Views/approbation_sorties_page.dart b/lib/Views/approbation_sorties_page.dart new file mode 100644 index 0000000..e375c86 --- /dev/null +++ b/lib/Views/approbation_sorties_page.dart @@ -0,0 +1,451 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:youmazgestion/Components/app_bar.dart'; +import 'package:youmazgestion/Components/appDrawer.dart'; +import 'package:youmazgestion/Services/stock_managementDatabase.dart'; +import 'package:youmazgestion/controller/userController.dart'; + +class ApprobationSortiesPage extends StatefulWidget { + const ApprobationSortiesPage({super.key}); + + @override + _ApprobationSortiesPageState createState() => _ApprobationSortiesPageState(); +} + +class _ApprobationSortiesPageState extends State { + final AppDatabase _database = AppDatabase.instance; + final UserController _userController = Get.find(); + + List> _sortiesEnAttente = []; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + _loadSortiesEnAttente(); + } + + Future _loadSortiesEnAttente() async { + setState(() => _isLoading = true); + try { + final sorties = await _database.getSortiesPersonnellesEnAttente(); + setState(() { + _sortiesEnAttente = sorties; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + Get.snackbar('Erreur', 'Impossible de charger les demandes: $e'); + } + } + + Future _approuverSortie(Map sortie) async { + final confirm = await Get.dialog( + AlertDialog( + title: const Text('Approuver la demande'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Produit: ${sortie['produit_nom']}'), + Text('Quantité: ${sortie['quantite']}'), + Text('Demandeur: ${sortie['admin_nom']}'), + Text('Motif: ${sortie['motif']}'), + const SizedBox(height: 16), + const Text( + 'Confirmer l\'approbation de cette demande de sortie personnelle ?', + style: TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Get.back(result: true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.green), + child: const Text('Approuver', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm == true) { + try { + await _database.approuverSortiePersonnelle( + sortie['id'] as int, + _userController.userId, + ); + + Get.snackbar( + 'Demande approuvée', + 'La sortie personnelle a été approuvée et le stock mis à jour', + backgroundColor: Colors.green, + colorText: Colors.white, + ); + + _loadSortiesEnAttente(); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible d\'approuver la demande: $e', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + } + + Future _refuserSortie(Map sortie) async { + final motifController = TextEditingController(); + + final confirm = await Get.dialog( + AlertDialog( + title: const Text('Refuser la demande'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('Demande de: ${sortie['admin_nom']}'), + Text('Produit: ${sortie['produit_nom']}'), + const SizedBox(height: 16), + TextField( + controller: motifController, + decoration: const InputDecoration( + labelText: 'Motif du refus *', + border: OutlineInputBorder(), + ), + maxLines: 3, + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () { + if (motifController.text.trim().isNotEmpty) { + Get.back(result: true); + } else { + Get.snackbar('Erreur', 'Veuillez indiquer un motif de refus'); + } + }, + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + child: const Text('Refuser', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + + if (confirm == true && motifController.text.trim().isNotEmpty) { + try { + await _database.refuserSortiePersonnelle( + sortie['id'] as int, + _userController.userId, + motifController.text.trim(), + ); + + Get.snackbar( + 'Demande refusée', + 'La sortie personnelle a été refusée', + backgroundColor: Colors.orange, + colorText: Colors.white, + ); + + _loadSortiesEnAttente(); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible de refuser la demande: $e', + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar(title: 'Approbation sorties personnelles'), + drawer: CustomDrawer(), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadSortiesEnAttente, + child: _sortiesEnAttente.isEmpty + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.inbox, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'Aucune demande en attente', + style: TextStyle(fontSize: 18, color: Colors.grey), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _sortiesEnAttente.length, + itemBuilder: (context, index) { + final sortie = _sortiesEnAttente[index]; + return _buildSortieCard(sortie); + }, + ), + ), + ); + } + + Widget _buildSortieCard(Map sortie) { + final dateSortie = DateTime.parse(sortie['date_sortie'].toString()); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avec statut + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'EN ATTENTE', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.orange.shade700, + ), + ), + ), + const Spacer(), + Text( + DateFormat('dd/MM/yyyy HH:mm').format(dateSortie), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Informations du produit + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.inventory, color: Colors.blue.shade700, size: 16), + const SizedBox(width: 8), + Text( + 'Produit demandé', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.blue.shade700, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + sortie['produit_nom'].toString(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + Text('Référence: ${sortie['produit_reference'] ?? 'N/A'}'), + const SizedBox(width: 16), + Text('Stock actuel: ${sortie['stock_actuel']}'), + const SizedBox(width: 16), + Text( + 'Quantité demandée: ${sortie['quantite']}', + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // Informations du demandeur + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.person, color: Colors.green.shade700, size: 16), + const SizedBox(width: 8), + Text( + 'Demandeur', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.green.shade700, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '${sortie['admin_nom']} ${sortie['admin_nom_famille'] ?? ''}', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + if (sortie['point_vente_nom'] != null) + Text('Point de vente: ${sortie['point_vente_nom']}'), + ], + ), + ), + const SizedBox(height: 12), + + // Motif + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.purple.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.note, color: Colors.purple.shade700, size: 16), + const SizedBox(width: 8), + Text( + 'Motif', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.purple.shade700, + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + sortie['motif'].toString(), + style: const TextStyle(fontSize: 14), + ), + if (sortie['notes'] != null && sortie['notes'].toString().isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Notes: ${sortie['notes']}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontStyle: FontStyle.italic, + ), + ), + ], + ], + ), + ), + const SizedBox(height: 16), + + // Vérification de stock + if ((sortie['stock_actuel'] as int) < (sortie['quantite'] as int)) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade300), + ), + child: Row( + children: [ + Icon(Icons.warning, color: Colors.red.shade700), + const SizedBox(width: 8), + Expanded( + child: Text( + 'ATTENTION: Stock insuffisant pour cette demande', + style: TextStyle( + color: Colors.red.shade700, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // Boutons d'action + Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: () => _refuserSortie(sortie), + icon: const Icon(Icons.close, size: 18), + label: const Text('Refuser'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: ((sortie['stock_actuel'] as int) >= (sortie['quantite'] as int)) + ? () => _approuverSortie(sortie) + : null, + icon: const Icon(Icons.check, size: 18), + label: const Text('Approuver'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green.shade600, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ), + ), + ], + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/Views/commandManagement.dart b/lib/Views/commandManagement.dart index 1b23a2d..1663326 100644 --- a/lib/Views/commandManagement.dart +++ b/lib/Views/commandManagement.dart @@ -297,16 +297,16 @@ Future _generateBonLivraison(Commande commande) async { final image = pw.MemoryImage(imageBytes); final italicFont = pw.Font.ttf(await rootBundle.load('assets/fonts/Roboto-Italic.ttf')); - // Tailles de texte encore plus réduites pour 2 exemplaires - final tinyTextStyle = pw.TextStyle(fontSize: 6); - final smallTextStyle = pw.TextStyle(fontSize: 7); - final normalTextStyle = pw.TextStyle(fontSize: 8); - final boldTextStyle = pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold); - final boldClientStyle = pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold); - final frameTextStyle = pw.TextStyle(fontSize: 7); - final italicTextStyle = pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold, font: italicFont); - final italicLogoStyle = pw.TextStyle(fontSize: 5, fontWeight: pw.FontWeight.bold, font: italicFont); - final titleStyle = pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold); + // Tailles de texte adaptées pour côte à côte + final tinyTextStyle = pw.TextStyle(fontSize: 5); + final smallTextStyle = pw.TextStyle(fontSize: 6); + final normalTextStyle = pw.TextStyle(fontSize: 7); + final boldTextStyle = pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold); + final boldClientStyle = pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold); + final frameTextStyle = pw.TextStyle(fontSize: 6); + final italicTextStyle = pw.TextStyle(fontSize: 5, fontWeight: pw.FontWeight.bold, font: italicFont); + final italicLogoStyle = pw.TextStyle(fontSize: 4, fontWeight: pw.FontWeight.bold, font: italicFont); + final titleStyle = pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold); // Fonction pour créer un exemplaire pw.Widget buildExemplaire(String typeExemplaire, {bool isSecond = false}) { @@ -321,7 +321,7 @@ Future _generateBonLivraison(Commande commande) async { // En-tête avec indication de l'exemplaire pw.Container( width: double.infinity, - padding: const pw.EdgeInsets.all(4), + padding: const pw.EdgeInsets.all(3), decoration: pw.BoxDecoration( color: typeExemplaire == "CLIENT" ? PdfColors.blue100 : PdfColors.green100, ), @@ -329,7 +329,7 @@ Future _generateBonLivraison(Commande commande) async { child: pw.Text( 'BON DE LIVRAISON - EXEMPLAIRE $typeExemplaire', style: pw.TextStyle( - fontSize: 9, + fontSize: 8, fontWeight: pw.FontWeight.bold, color: typeExemplaire == "CLIENT" ? PdfColors.blue800 : PdfColors.green800, ), @@ -338,7 +338,7 @@ Future _generateBonLivraison(Commande commande) async { ), pw.Padding( - padding: const pw.EdgeInsets.all(8), + padding: const pw.EdgeInsets.all(6), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ @@ -352,12 +352,12 @@ Future _generateBonLivraison(Commande commande) async { crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Container( - width: 60, - height: 60, + width: 45, + height: 45, child: pw.Image(image), ), pw.Text('NOTRE COMPETENCE, A VOTRE SERVICE', style: italicLogoStyle), - pw.SizedBox(height: 4), + pw.SizedBox(height: 3), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ @@ -365,9 +365,9 @@ Future _generateBonLivraison(Commande commande) async { pw.Text('📍 SUPREME CENTER Behoririka', style: tinyTextStyle), pw.Text('📞 033 37 808 18', style: tinyTextStyle), pw.Text('🌐 www.guycom.mg', style: tinyTextStyle), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), // Ajout du NIF - pw.Text('NIF: 1026/GC78-20-02-22', style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold)), + pw.Text('NIF: 1026/GC78-20-02-22', style: pw.TextStyle(fontSize: 5, fontWeight: pw.FontWeight.bold)), ], ), ], @@ -378,11 +378,11 @@ Future _generateBonLivraison(Commande commande) async { crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ pw.Text('Date: ${DateFormat('dd/MM/yyyy').format(DateTime.now())}', style: boldClientStyle), - pw.SizedBox(height: 4), - pw.Container(width: 100, height: 1, color: PdfColors.black), - pw.SizedBox(height: 4), + pw.SizedBox(height: 3), + pw.Container(width: 80, height: 1, color: PdfColors.black), + pw.SizedBox(height: 3), pw.Container( - padding: const pw.EdgeInsets.all(4), + padding: const pw.EdgeInsets.all(3), decoration: pw.BoxDecoration( border: pw.Border.all(color: PdfColors.black), ), @@ -390,7 +390,7 @@ Future _generateBonLivraison(Commande commande) async { children: [ pw.Text('Boutique:', style: frameTextStyle), pw.Text('${pointDeVente?['nom'] ?? 'S405A'}', style: boldTextStyle), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), pw.Text('Bon N°:', style: frameTextStyle), pw.Text('${pointDeVente?['nom'] ?? 'S405A'}-P${commande.id}', style: boldTextStyle), ], @@ -401,20 +401,20 @@ Future _generateBonLivraison(Commande commande) async { // Informations client - compact pw.Container( - width: 130, + width: 100, decoration: pw.BoxDecoration( border: pw.Border.all(color: PdfColors.black, width: 1), ), - padding: const pw.EdgeInsets.all(6), + padding: const pw.EdgeInsets.all(4), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.center, children: [ pw.Text('CLIENT', style: frameTextStyle), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), pw.Text('ID: ${pointDeVente?['nom'] ?? 'S405A'}-${client?.id ?? 'Non spécifié'}', style: smallTextStyle), - pw.Container(width: 100, height: 1, color: PdfColors.black, margin: const pw.EdgeInsets.symmetric(vertical: 2)), + pw.Container(width: 80, height: 1, color: PdfColors.black, margin: const pw.EdgeInsets.symmetric(vertical: 1)), pw.Text(client?.nom ?? 'Non spécifié', style: boldTextStyle), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), pw.Text(client?.telephone ?? 'Non spécifié', style: tinyTextStyle), ], ), @@ -422,7 +422,7 @@ Future _generateBonLivraison(Commande commande) async { ], ), - pw.SizedBox(height: 6), + pw.SizedBox(height: 4), // Tableau des produits - très compact pw.Table( @@ -438,11 +438,11 @@ Future _generateBonLivraison(Commande commande) async { pw.TableRow( decoration: const pw.BoxDecoration(color: PdfColors.grey200), children: [ - pw.Padding(padding: const pw.EdgeInsets.all(2), child: pw.Text('Désignations', style: boldTextStyle)), - pw.Padding(padding: const pw.EdgeInsets.all(2), child: pw.Text('Qté', style: boldTextStyle, textAlign: pw.TextAlign.center)), - pw.Padding(padding: const pw.EdgeInsets.all(2), child: pw.Text('P.U.', style: boldTextStyle, textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(2), child: pw.Text('Remise/Cadeau', style: boldTextStyle, textAlign: pw.TextAlign.center)), - pw.Padding(padding: const pw.EdgeInsets.all(2), child: pw.Text('Montant', style: boldTextStyle, textAlign: pw.TextAlign.right)), + pw.Padding(padding: const pw.EdgeInsets.all(1), child: pw.Text('Désignations', style: boldTextStyle)), + pw.Padding(padding: const pw.EdgeInsets.all(1), child: pw.Text('Qté', style: boldTextStyle, textAlign: pw.TextAlign.center)), + pw.Padding(padding: const pw.EdgeInsets.all(1), child: pw.Text('P.U.', style: boldTextStyle, textAlign: pw.TextAlign.right)), + pw.Padding(padding: const pw.EdgeInsets.all(1), child: pw.Text('Remise/Cadeau', style: boldTextStyle, textAlign: pw.TextAlign.center)), + pw.Padding(padding: const pw.EdgeInsets.all(1), child: pw.Text('Montant', style: boldTextStyle, textAlign: pw.TextAlign.right)), ], ), @@ -458,7 +458,7 @@ Future _generateBonLivraison(Commande commande) async { : null, children: [ pw.Padding( - padding: const pw.EdgeInsets.all(2), + padding: const pw.EdgeInsets.all(1), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ @@ -466,16 +466,16 @@ Future _generateBonLivraison(Commande commande) async { children: [ pw.Expanded( child: pw.Text(detail.produitNom ?? 'Produit inconnu', - style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)), + style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold)), ), if (detail.estCadeau) pw.Container( - padding: const pw.EdgeInsets.symmetric(horizontal: 2, vertical: 1), + padding: const pw.EdgeInsets.symmetric(horizontal: 1, vertical: 0.5), decoration: pw.BoxDecoration( color: PdfColors.green, borderRadius: pw.BorderRadius.circular(2), ), - child: pw.Text('🎁', style: pw.TextStyle(fontSize: 5, color: PdfColors.white)), + child: pw.Text('🎁', style: pw.TextStyle(fontSize: 4, color: PdfColors.white)), ), ], ), @@ -487,30 +487,30 @@ Future _generateBonLivraison(Commande commande) async { ), ), pw.Padding( - padding: const pw.EdgeInsets.all(2), + padding: const pw.EdgeInsets.all(1), child: pw.Text('${detail.quantite}', style: normalTextStyle, textAlign: pw.TextAlign.center), ), pw.Padding( - padding: const pw.EdgeInsets.all(2), + padding: const pw.EdgeInsets.all(1), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ if (detail.estCadeau) ...[ pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 5, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), - pw.Text('GRATUIT', style: pw.TextStyle(fontSize: 6, color: PdfColors.green700, fontWeight: pw.FontWeight.bold)), + style: pw.TextStyle(fontSize: 4, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), + pw.Text('GRATUIT', style: pw.TextStyle(fontSize: 5, color: PdfColors.green700, fontWeight: pw.FontWeight.bold)), ] else if (detail.aRemise) ...[ pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 5, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), + style: pw.TextStyle(fontSize: 4, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), pw.Text('${(detail.prixFinal / detail.quantite).toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 7, color: PdfColors.orange)), + style: pw.TextStyle(fontSize: 6, color: PdfColors.orange)), ] else pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', style: smallTextStyle), ], ), ), pw.Padding( - padding: const pw.EdgeInsets.all(2), + padding: const pw.EdgeInsets.all(1), child: pw.Text( detail.estCadeau ? 'CADEAU' @@ -518,7 +518,7 @@ Future _generateBonLivraison(Commande commande) async { ? 'REMISE' : '-', style: pw.TextStyle( - fontSize: 6, + fontSize: 5, color: detail.estCadeau ? PdfColors.green700 : detail.aRemise ? PdfColors.orange : PdfColors.grey600, fontWeight: detail.estCadeau ? pw.FontWeight.bold : pw.FontWeight.normal, ), @@ -526,18 +526,18 @@ Future _generateBonLivraison(Commande commande) async { ), ), pw.Padding( - padding: const pw.EdgeInsets.all(2), + padding: const pw.EdgeInsets.all(1), child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ if (detail.estCadeau) ...[ pw.Text('${detail.sousTotal.toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 5, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), - pw.Text('GRATUIT', style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold, color: PdfColors.green700)), + style: pw.TextStyle(fontSize: 4, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), + pw.Text('GRATUIT', style: pw.TextStyle(fontSize: 5, fontWeight: pw.FontWeight.bold, color: PdfColors.green700)), ] else if (detail.aRemise) ...[ pw.Text('${detail.sousTotal.toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 5, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), - pw.Text('${detail.prixFinal.toStringAsFixed(0)}', style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)), + style: pw.TextStyle(fontSize: 4, decoration: pw.TextDecoration.lineThrough, color: PdfColors.grey600)), + pw.Text('${detail.prixFinal.toStringAsFixed(0)}', style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold)), ] else pw.Text('${detail.prixFinal.toStringAsFixed(0)}', style: smallTextStyle), ], @@ -549,7 +549,7 @@ Future _generateBonLivraison(Commande commande) async { ], ), - pw.SizedBox(height: 6), + pw.SizedBox(height: 4), // Section finale - très compacte pw.Row( @@ -566,59 +566,59 @@ Future _generateBonLivraison(Commande commande) async { mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Text('SOUS-TOTAL:', style: smallTextStyle), - pw.SizedBox(width: 10), + pw.SizedBox(width: 8), pw.Text('${sousTotal.toStringAsFixed(0)}', style: smallTextStyle), ], ), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), ], if (totalRemises > 0) ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ - pw.Text('REMISES:', style: pw.TextStyle(color: PdfColors.orange, fontSize: 7)), - pw.SizedBox(width: 10), - pw.Text('-${totalRemises.toStringAsFixed(0)}', style: pw.TextStyle(color: PdfColors.orange, fontSize: 7)), + pw.Text('REMISES:', style: pw.TextStyle(color: PdfColors.orange, fontSize: 6)), + pw.SizedBox(width: 8), + pw.Text('-${totalRemises.toStringAsFixed(0)}', style: pw.TextStyle(color: PdfColors.orange, fontSize: 6)), ], ), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), ], if (totalCadeaux > 0) ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ - pw.Text('CADEAUX ($nombreCadeaux):', style: pw.TextStyle(color: PdfColors.green700, fontSize: 7)), - pw.SizedBox(width: 10), - pw.Text('-${totalCadeaux.toStringAsFixed(0)}', style: pw.TextStyle(color: PdfColors.green700, fontSize: 7)), + pw.Text('CADEAUX ($nombreCadeaux):', style: pw.TextStyle(color: PdfColors.green700, fontSize: 6)), + pw.SizedBox(width: 8), + pw.Text('-${totalCadeaux.toStringAsFixed(0)}', style: pw.TextStyle(color: PdfColors.green700, fontSize: 6)), ], ), - pw.SizedBox(height: 2), + pw.SizedBox(height: 1), ], - pw.Container(width: 120, height: 1, color: PdfColors.black, margin: const pw.EdgeInsets.symmetric(vertical: 2)), + pw.Container(width: 100, height: 1, color: PdfColors.black, margin: const pw.EdgeInsets.symmetric(vertical: 1)), pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Text('TOTAL:', style: boldTextStyle), - pw.SizedBox(width: 10), + pw.SizedBox(width: 8), pw.Text('${commande.montantTotal.toStringAsFixed(0)} MGA', style: boldTextStyle), ], ), if (totalCadeaux > 0) ...[ - pw.SizedBox(height: 4), + pw.SizedBox(height: 3), pw.Container( - padding: const pw.EdgeInsets.all(4), + padding: const pw.EdgeInsets.all(3), decoration: pw.BoxDecoration( color: PdfColors.green50, borderRadius: pw.BorderRadius.circular(3), ), child: pw.Text( '🎁 $nombreCadeaux cadeau(s) offert(s) (${totalCadeaux.toStringAsFixed(0)} MGA)', - style: pw.TextStyle(fontSize: 6, color: PdfColors.green700), + style: pw.TextStyle(fontSize: 5, color: PdfColors.green700), ), ), ], @@ -626,7 +626,7 @@ Future _generateBonLivraison(Commande commande) async { ), ), - pw.SizedBox(width: 15), + pw.SizedBox(width: 10), // Informations vendeurs et signatures pw.Expanded( @@ -636,7 +636,7 @@ Future _generateBonLivraison(Commande commande) async { children: [ // Vendeurs pw.Container( - padding: const pw.EdgeInsets.all(4), + padding: const pw.EdgeInsets.all(3), decoration: pw.BoxDecoration( color: PdfColors.grey100, borderRadius: pw.BorderRadius.circular(3), @@ -644,8 +644,8 @@ Future _generateBonLivraison(Commande commande) async { child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Text('VENDEURS', style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold)), - pw.SizedBox(height: 2), + pw.Text('VENDEURS', style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 1), pw.Row( children: [ pw.Expanded( @@ -655,7 +655,7 @@ Future _generateBonLivraison(Commande commande) async { pw.Text('Initiateur:', style: tinyTextStyle), pw.Text( commandeur != null ? '${commandeur.name} ${commandeur.lastName ?? ''}'.trim() : 'N/A', - style: pw.TextStyle(fontSize: 6), + style: pw.TextStyle(fontSize: 5), ), ], ), @@ -667,7 +667,7 @@ Future _generateBonLivraison(Commande commande) async { pw.Text('Validateur:', style: tinyTextStyle), pw.Text( validateur != null ? '${validateur.name} ${validateur.lastName ?? ''}'.trim() : 'N/A', - style: pw.TextStyle(fontSize: 6), + style: pw.TextStyle(fontSize: 5), ), ], ), @@ -678,7 +678,7 @@ Future _generateBonLivraison(Commande commande) async { ), ), - pw.SizedBox(height: 8), + pw.SizedBox(height: 6), // Signatures pw.Row( @@ -686,16 +686,16 @@ Future _generateBonLivraison(Commande commande) async { children: [ pw.Column( children: [ - pw.Text('Vendeur', style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold)), - pw.SizedBox(height: 10), - pw.Container(width: 60, height: 1, color: PdfColors.black), + pw.Text('Vendeur', style: pw.TextStyle(fontSize: 5, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 8), + pw.Container(width: 50, height: 1, color: PdfColors.black), ], ), pw.Column( children: [ - pw.Text('Client', style: pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold)), - pw.SizedBox(height: 10), - pw.Container(width: 60, height: 1, color: PdfColors.black), + pw.Text('Client', style: pw.TextStyle(fontSize: 5, fontWeight: pw.FontWeight.bold)), + pw.SizedBox(height: 8), + pw.Container(width: 50, height: 1, color: PdfColors.black), ], ), ], @@ -706,7 +706,7 @@ Future _generateBonLivraison(Commande commande) async { ], ), - pw.SizedBox(height: 4), + pw.SizedBox(height: 3), // Note finale pw.Text( @@ -726,33 +726,35 @@ Future _generateBonLivraison(Commande commande) async { pageFormat: PdfPageFormat.a4.landscape, margin: const pw.EdgeInsets.all(10), build: (pw.Context context) { - return pw.Column( + return pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - // Exemplaire CLIENT + // Exemplaire CLIENT (à gauche) pw.Expanded( child: buildExemplaire("CLIENT"), ), - pw.SizedBox(height: 8), + pw.SizedBox(width: 10), - // Ligne de séparation avec ciseaux - pw.Container( - width: double.infinity, - child: pw.Row( - children: [ - pw.Expanded(child: pw.Container(height: 1, color: PdfColors.grey400)), - pw.Padding( - padding: const pw.EdgeInsets.symmetric(horizontal: 8), - child: pw.Text('✂️ DÉCOUPER ICI ✂️', style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600)), - ), - pw.Expanded(child: pw.Container(height: 1, color: PdfColors.grey400)), - ], - ), + // Ligne de séparation verticale avec ciseaux + pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, + children: [ + pw.Transform.rotate( + angle: 3.14159 / 2, // 90 degrés en radians + child: pw.Text('✂️ DÉCOUPER ICI ✂️', style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600)), + ), + pw.Container( + width: 1, + height: 200, + color: PdfColors.grey400, + ), + ], ), - pw.SizedBox(height: 8), + pw.SizedBox(width: 10), - // Exemplaire MAGASIN + // Exemplaire MAGASIN (à droite) pw.Expanded( child: buildExemplaire("MAGASIN", isSecond: true), ), @@ -779,7 +781,7 @@ Future _generateInvoice(Commande commande) async { final client = await _database.getClientById(commande.clientId); final pointDeVente = await _database.getPointDeVenteById(1); - // NOUVEAU: Récupérer les informations des vendeurs + // Récupérer les informations des vendeurs final commandeur = commande.commandeurId != null ? await _database.getUserById(commande.commandeurId!) : null; @@ -820,317 +822,357 @@ Future _generateInvoice(Commande commande) async { final image = pw.MemoryImage(imageBytes); final italicFont = pw.Font.ttf(await rootBundle.load('assets/fonts/Roboto-Italic.ttf')); - // Tailles de texte réduites pour le mode paysage - final smallTextStyle = pw.TextStyle(fontSize: 7); - final normalTextStyle = pw.TextStyle(fontSize: 8); - final boldTextStyle = pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold); - final boldTexClienttStyle = pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold); - final frameTextStyle = pw.TextStyle(fontSize: 8); - final italicTextStyle = pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold, font: italicFont); - final italicTextStyleLogo = pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold, font: italicFont); - final emojiSuportFont = pw.Font.ttf( await rootBundle.load('assets/NotoEmoji-Regular.ttf')); - final emojifont = pw.TextStyle(fontSize: 6, fontWeight: pw.FontWeight.bold, font: emojiSuportFont); + // Tailles de texte adaptées pour le mode portrait + final smallTextStyle = pw.TextStyle(fontSize: 8); + final normalTextStyle = pw.TextStyle(fontSize: 9); + final boldTextStyle = pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold); + final boldClientTextStyle = pw.TextStyle(fontSize: 11, fontWeight: pw.FontWeight.bold); + final frameTextStyle = pw.TextStyle(fontSize: 9); + final italicTextStyle = pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold, font: italicFont); + final italicTextStyleLogo = pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold, font: italicFont); + final emojiSuportFont = pw.Font.ttf(await rootBundle.load('assets/NotoEmoji-Regular.ttf')); + final emojifont = pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold, font: emojiSuportFont); pdf.addPage( pw.Page( - pageFormat: PdfPageFormat.a4.landscape, // Mode paysage - margin: const pw.EdgeInsets.all(15), // Marges réduites + pageFormat: PdfPageFormat.a4, // Mode portrait + margin: const pw.EdgeInsets.all(20), // Marges normales build: (pw.Context context) { return pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - // En-tête avec logo et informations - optimisé pour paysage + // En-tête avec logo et informations - optimisé pour portrait pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - // Section logo et adresses - réduite + // Section logo et adresses pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Container( - width: 100, // Taille logo réduite - height: 100, + width: 120, + height: 120, child: pw.Image(image), ), pw.Text(' NOTRE COMPETENCE, A VOTRE SERVICE', style: italicTextStyleLogo), - pw.SizedBox(height: 8), + pw.SizedBox(height: 10), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Row(children: [iconChecked, pw.SizedBox(width: 3), pw.Text('REMAX by GUYCOM Andravoangy', style: smallTextStyle)]), - pw.Row(children: [iconChecked, pw.SizedBox(width: 3), pw.Text('SUPREME CENTER Behoririka box 405', style: smallTextStyle)]), - pw.Row(children: [iconChecked, pw.SizedBox(width: 3), pw.Text('SUPREME CENTER Behoririka box 416', style: smallTextStyle)]), - pw.Row(children: [iconChecked, pw.SizedBox(width: 3), pw.Text('SUPREME CENTER Behoririka box 119', style: smallTextStyle)]), - pw.Row(children: [iconChecked, pw.SizedBox(width: 3), pw.Text('TRIPOLITSA Analakely BOX 7', style: smallTextStyle)]), + pw.Row(children: [iconChecked, pw.SizedBox(width: 4), pw.Text('REMAX by GUYCOM Andravoangy', style: smallTextStyle)]), + pw.SizedBox(height: 2), + pw.Row(children: [iconChecked, pw.SizedBox(width: 4), pw.Text('SUPREME CENTER Behoririka box 405', style: smallTextStyle)]), + pw.SizedBox(height: 2), + pw.Row(children: [iconChecked, pw.SizedBox(width: 4), pw.Text('SUPREME CENTER Behoririka box 416', style: smallTextStyle)]), + pw.SizedBox(height: 2), + pw.Row(children: [iconChecked, pw.SizedBox(width: 4), pw.Text('SUPREME CENTER Behoririka box 119', style: smallTextStyle)]), + pw.SizedBox(height: 2), + pw.Row(children: [iconChecked, pw.SizedBox(width: 4), pw.Text('TRIPOLITSA Analakely BOX 7', style: smallTextStyle)]), ], ), - pw.SizedBox(height: 6), - pw.Row(children: [iconPhone, pw.SizedBox(width: 3), pw.Text('033 37 808 18', style: smallTextStyle)]), - pw.Row(children: [iconGlobe, pw.SizedBox(width: 3), pw.Text('www.guycom.mg', style: smallTextStyle)]), + pw.SizedBox(height: 8), + pw.Row(children: [iconPhone, pw.SizedBox(width: 4), pw.Text('033 37 808 18', style: smallTextStyle)]), + pw.Row(children: [iconGlobe, pw.SizedBox(width: 4), pw.Text('www.guycom.mg', style: smallTextStyle)]), pw.Text('Facebook: GuyCom', style: smallTextStyle), ], ), - // Section centrale - informations commande + // Section droite - informations commande et client pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.center, + crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ - pw.Text('Date: ${DateFormat('dd/MM/yyyy').format(DateTime.now())}', style: boldTexClienttStyle), - pw.SizedBox(height: 6), - pw.Container(width: 150, height: 1, color: PdfColors.black), - pw.SizedBox(height: 6), + pw.Text('Date: ${DateFormat('dd/MM/yyyy').format(DateTime.now())}', style: boldClientTextStyle), + pw.SizedBox(height: 8), + pw.Container(width: 200, height: 1, color: PdfColors.black), + pw.SizedBox(height: 10), + + // Informations boutique et facture pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Container( - width: 80, - height: 35, - padding: const pw.EdgeInsets.all(4), + width: 100, + height: 45, + padding: const pw.EdgeInsets.all(6), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.black, width: 1), + ), child: pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, children: [ pw.Text('Boutique:', style: frameTextStyle), - pw.Text('${pointDeVente?['nom'] ?? 'S405A'}', style: boldTexClienttStyle), + pw.SizedBox(height: 2), + pw.Text('${pointDeVente?['nom'] ?? 'S405A'}', style: boldClientTextStyle), ] ) ), - pw.SizedBox(width: 8), + pw.SizedBox(width: 10), pw.Container( - width: 80, - height: 35, - padding: const pw.EdgeInsets.all(4), + width: 100, + height: 45, + padding: const pw.EdgeInsets.all(6), + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.black, width: 1), + ), child: pw.Column( + mainAxisAlignment: pw.MainAxisAlignment.center, children: [ pw.Text('Facture N°:', style: frameTextStyle), - pw.Text('${pointDeVente?['nom'] ?? 'S405A'}-P${commande.id}', style: boldTexClienttStyle), + pw.SizedBox(height: 2), + pw.Text('${pointDeVente?['nom'] ?? 'S405A'}-P${commande.id}', style: boldClientTextStyle), ] ) ), ], ), + + pw.SizedBox(height: 15), + + // Section client + pw.Container( + width: 220, + height: 100, + decoration: pw.BoxDecoration( + border: pw.Border.all(color: PdfColors.black, width: 1), + ), + padding: const pw.EdgeInsets.all(10), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text('ID Client: ', style: frameTextStyle), + pw.SizedBox(height: 4), + pw.Text('${pointDeVente?['nom'] ?? 'S405A'} - ${client?.id ?? 'Non spécifié'}', style: boldClientTextStyle), + pw.SizedBox(height: 6), + pw.Container(width: 180, height: 1, color: PdfColors.black), + pw.SizedBox(height: 4), + pw.Text(client?.nom ?? 'Non spécifié', style: boldClientTextStyle), + pw.SizedBox(height: 4), + pw.Text(client?.telephone ?? 'Non spécifié', style: frameTextStyle), + ], + ), + ), ], ), - - // Section client - compacte - pw.Container( - width: 200, - height: 80, - decoration: pw.BoxDecoration( - border: pw.Border.all(color: PdfColors.black, width: 1), - ), - padding: const pw.EdgeInsets.all(8), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.center, - children: [ - pw.Text('ID Client: ', style: frameTextStyle), - pw.SizedBox(height: 3), - pw.Text('${pointDeVente?['nom'] ?? 'S405A'} - ${client?.id ?? 'Non spécifié'}', style: boldTexClienttStyle), - pw.SizedBox(height: 3), - pw.Container(width: 150, height: 1, color: PdfColors.black), - pw.Text(client?.nom ?? 'Non spécifié', style: boldTexClienttStyle), - pw.SizedBox(height: 3), - pw.Text(client?.telephone ?? 'Non spécifié', style: frameTextStyle), - ], - ), - ), ], ), - pw.SizedBox(height: 10), + pw.SizedBox(height: 15), - // Tableau des produits avec cadeaux - optimisé pour paysage - pw.Table( - border: pw.TableBorder.all(width: 0.5), - columnWidths: { - 0: const pw.FlexColumnWidth(4), // Plus d'espace pour les désignations - 1: const pw.FlexColumnWidth(1), - 2: const pw.FlexColumnWidth(1.5), - 3: const pw.FlexColumnWidth(2), // Colonne remise/cadeau - 4: const pw.FlexColumnWidth(1.5), // Colonne montant - }, - children: [ - pw.TableRow( - decoration: const pw.BoxDecoration(color: PdfColors.grey200), - children: [ - pw.Padding(padding: const pw.EdgeInsets.all(3), child: pw.Text('Désignations', style: boldTextStyle)), - pw.Padding(padding: const pw.EdgeInsets.all(3), child: pw.Text('Qté', style: boldTextStyle, textAlign: pw.TextAlign.center)), - pw.Padding(padding: const pw.EdgeInsets.all(3), child: pw.Text('Prix unitaire', style: boldTextStyle, textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(3), child: pw.Text('Remise/Cadeau', style: boldTextStyle, textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(3), child: pw.Text('Montant', style: boldTextStyle, textAlign: pw.TextAlign.right)), - ], - ), - - ...detailsAvecProduits.map((item) { - final detail = item['detail'] as DetailCommande; - final produit = item['produit']; - - return pw.TableRow( - decoration: detail.estCadeau - ? const pw.BoxDecoration( - color: PdfColors.green50, - border: pw.Border( - left: pw.BorderSide( - color: PdfColors.green300, - width: 3, - ), - ), - ) - : detail.aRemise - ? const pw.BoxDecoration( - color: PdfColors.orange50, - border: pw.Border( - left: pw.BorderSide( - color: PdfColors.orange300, - width: 3, - ), - ), - ) - : null, + // Tableau des produits avec cadeaux - optimisé pour portrait + pw.Table( + border: pw.TableBorder.all(width: 0.5), + columnWidths: { + 0: const pw.FlexColumnWidth(3), // Désignations + 1: const pw.FlexColumnWidth(0.8), // Quantité + 2: const pw.FlexColumnWidth(1.2), // Prix unitaire + 3: const pw.FlexColumnWidth(1.5), // Remise/cadeau + 4: const pw.FlexColumnWidth(1.2), // Montant + }, + children: [ + pw.TableRow( + decoration: const pw.BoxDecoration(color: PdfColors.grey200), children: [ pw.Padding( - padding: const pw.EdgeInsets.all(3), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Row( - children: [ - pw.Expanded( - child: pw.Text(detail.produitNom ?? 'Produit inconnu', - style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)), - ), - if (detail.estCadeau) - pw.Container( - padding: const pw.EdgeInsets.symmetric(horizontal: 3, vertical: 1), - decoration: pw.BoxDecoration( - color: PdfColors.green100, - borderRadius: pw.BorderRadius.circular(3), - ), - child: pw.Text( - 'CADEAU', - style: pw.TextStyle( - fontSize: 6, - fontWeight: pw.FontWeight.bold, - color: PdfColors.green700, - ), - ), - ), - ], - ), - pw.SizedBox(height: 1), - if (produit?.category != null && produit!.category.isNotEmpty && produit?.marque != null && produit!.marque.isNotEmpty) - pw.Text('${produit.category} - ${produit.marque}', style: smallTextStyle), - if (produit?.imei != null && produit!.imei!.isNotEmpty) - pw.Text('${produit.imei}', style: smallTextStyle), - if (produit?.reference != null && produit!.reference!.isNotEmpty && produit?.ram != null && produit!.ram!.isNotEmpty && produit?.memoireInterne != null && produit!.memoireInterne!.isNotEmpty) - pw.Text('${produit.ram} | ${produit.memoireInterne} | ${produit.reference}', style: smallTextStyle), - ], - ), + padding: const pw.EdgeInsets.all(4), + child: pw.Text('Désignations', style: boldTextStyle) ), pw.Padding( - padding: const pw.EdgeInsets.all(3), - child: pw.Text('${detail.quantite}', style: normalTextStyle, textAlign: pw.TextAlign.center), + padding: const pw.EdgeInsets.all(4), + child: pw.Text('Qté', style: boldTextStyle, textAlign: pw.TextAlign.center) ), pw.Padding( - padding: const pw.EdgeInsets.all(3), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - if (detail.estCadeau) ...[ - pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', - style: pw.TextStyle( - fontSize: 6, - decoration: pw.TextDecoration.lineThrough, - color: PdfColors.grey600, - )), - pw.Text('GRATUIT', - style: pw.TextStyle( - fontSize: 7, - color: PdfColors.green700, - fontWeight: pw.FontWeight.bold, - )), - ] else if (detail.aRemise && detail.prixUnitaire != detail.sousTotal / detail.quantite) ...[ - pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', - style: pw.TextStyle( - fontSize: 6, - decoration: pw.TextDecoration.lineThrough, - color: PdfColors.grey600, - )), - pw.Text('${(detail.prixFinal / detail.quantite).toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 8, color: PdfColors.orange)), - ] else - pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', - style: normalTextStyle), - ], - ), + padding: const pw.EdgeInsets.all(4), + child: pw.Text('Prix unitaire', style: boldTextStyle, textAlign: pw.TextAlign.right) ), pw.Padding( - padding: const pw.EdgeInsets.all(3), - child: pw.Text( - detail.estCadeau - ? 'CADEAU OFFERT' - : detail.aRemise - ? detail.remiseDescription - : '-', - style: pw.TextStyle( - fontSize: 7, - color: detail.estCadeau - ? PdfColors.green700 - : detail.aRemise - ? PdfColors.orange - : PdfColors.grey600, - fontWeight: detail.estCadeau ? pw.FontWeight.bold : pw.FontWeight.normal, - ), - textAlign: pw.TextAlign.right, - ), + padding: const pw.EdgeInsets.all(4), + child: pw.Text('Remise/Cadeau', style: boldTextStyle, textAlign: pw.TextAlign.center) ), pw.Padding( - padding: const pw.EdgeInsets.all(3), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - if (detail.estCadeau) ...[ - pw.Text('${detail.sousTotal.toStringAsFixed(0)}', - style: pw.TextStyle( - fontSize: 6, - decoration: pw.TextDecoration.lineThrough, - color: PdfColors.grey600, - )), - pw.Text('GRATUIT', - style: pw.TextStyle( - fontSize: 7, - fontWeight: pw.FontWeight.bold, - color: PdfColors.green700, - )), - ] else if (detail.aRemise && detail.sousTotal != detail.prixFinal) ...[ - pw.Text('${detail.sousTotal.toStringAsFixed(0)}', - style: pw.TextStyle( - fontSize: 6, - decoration: pw.TextDecoration.lineThrough, - color: PdfColors.grey600, - )), - pw.Text('${detail.prixFinal.toStringAsFixed(0)}', - style: pw.TextStyle(fontSize: 8, fontWeight: pw.FontWeight.bold)), - ] else - pw.Text('${detail.prixFinal.toStringAsFixed(0)}', - style: normalTextStyle), - ], - ), + padding: const pw.EdgeInsets.all(4), + child: pw.Text('Montant', style: boldTextStyle, textAlign: pw.TextAlign.right) ), ], - ); - }).toList(), - ], - ), + ), + + ...detailsAvecProduits.map((item) { + final detail = item['detail'] as DetailCommande; + final produit = item['produit']; + + return pw.TableRow( + decoration: detail.estCadeau + ? const pw.BoxDecoration( + color: PdfColors.green50, + border: pw.Border( + left: pw.BorderSide( + color: PdfColors.green300, + width: 3, + ), + ), + ) + : detail.aRemise + ? const pw.BoxDecoration( + color: PdfColors.orange50, + border: pw.Border( + left: pw.BorderSide( + color: PdfColors.orange300, + width: 3, + ), + ), + ) + : null, + children: [ + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Row( + children: [ + pw.Expanded( + child: pw.Text(detail.produitNom ?? 'Produit inconnu', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), + ), + if (detail.estCadeau) + pw.Container( + padding: const pw.EdgeInsets.symmetric(horizontal: 4, vertical: 2), + decoration: pw.BoxDecoration( + color: PdfColors.green100, + borderRadius: pw.BorderRadius.circular(4), + ), + child: pw.Text( + 'CADEAU', + style: pw.TextStyle( + fontSize: 7, + fontWeight: pw.FontWeight.bold, + color: PdfColors.green700, + ), + ), + ), + ], + ), + pw.SizedBox(height: 2), + if (produit?.category != null && produit!.category.isNotEmpty && produit?.marque != null && produit!.marque.isNotEmpty) + pw.Text('${produit.category} - ${produit.marque}', style: smallTextStyle), + if (produit?.imei != null && produit!.imei!.isNotEmpty) + pw.Text('IMEI: ${produit.imei}', style: smallTextStyle), + if (produit?.reference != null && produit!.reference!.isNotEmpty) + pw.Row( + children: [ + if (produit?.ram != null && produit!.ram!.isNotEmpty) + pw.Text('${produit.ram}', style: smallTextStyle), + if (produit?.memoireInterne != null && produit!.memoireInterne!.isNotEmpty) + pw.Text(' | ${produit.memoireInterne}', style: smallTextStyle), + pw.Text(' | ${produit.reference}', style: smallTextStyle), + ], + ), + ], + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text('${detail.quantite}', style: normalTextStyle, textAlign: pw.TextAlign.center), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + if (detail.estCadeau) ...[ + pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', + style: pw.TextStyle( + fontSize: 7, + decoration: pw.TextDecoration.lineThrough, + color: PdfColors.grey600, + )), + pw.Text('GRATUIT', + style: pw.TextStyle( + fontSize: 8, + color: PdfColors.green700, + fontWeight: pw.FontWeight.bold, + )), + ] else if (detail.aRemise && detail.prixUnitaire != detail.sousTotal / detail.quantite) ...[ + pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', + style: pw.TextStyle( + fontSize: 7, + decoration: pw.TextDecoration.lineThrough, + color: PdfColors.grey600, + )), + pw.Text('${(detail.prixFinal / detail.quantite).toStringAsFixed(0)}', + style: pw.TextStyle(fontSize: 9, color: PdfColors.orange)), + ] else + pw.Text('${detail.prixUnitaire.toStringAsFixed(0)}', + style: normalTextStyle), + ], + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Text( + detail.estCadeau + ? 'CADEAU\nOFFERT' + : detail.aRemise + ? detail.remiseDescription + : '-', + style: pw.TextStyle( + fontSize: 7, + color: detail.estCadeau + ? PdfColors.green700 + : detail.aRemise + ? PdfColors.orange + : PdfColors.grey600, + fontWeight: detail.estCadeau ? pw.FontWeight.bold : pw.FontWeight.normal, + ), + textAlign: pw.TextAlign.center, + ), + ), + pw.Padding( + padding: const pw.EdgeInsets.all(4), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + if (detail.estCadeau) ...[ + pw.Text('${detail.sousTotal.toStringAsFixed(0)}', + style: pw.TextStyle( + fontSize: 7, + decoration: pw.TextDecoration.lineThrough, + color: PdfColors.grey600, + )), + pw.Text('GRATUIT', + style: pw.TextStyle( + fontSize: 8, + fontWeight: pw.FontWeight.bold, + color: PdfColors.green700, + )), + ] else if (detail.aRemise && detail.sousTotal != detail.prixFinal) ...[ + pw.Text('${detail.sousTotal.toStringAsFixed(0)}', + style: pw.TextStyle( + fontSize: 7, + decoration: pw.TextDecoration.lineThrough, + color: PdfColors.grey600, + )), + pw.Text('${detail.prixFinal.toStringAsFixed(0)}', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold)), + ] else + pw.Text('${detail.prixFinal.toStringAsFixed(0)}', + style: normalTextStyle), + ], + ), + ), + ], + ); + }).toList(), + ], + ), - pw.SizedBox(height: 8), - - // Sections inférieures en colonnes pour optimiser l'espace - pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - // Colonne gauche - Totaux - pw.Expanded( - flex: 2, - child: pw.Column( + pw.SizedBox(height: 12), + + // Section totaux - alignée à droite + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + children: [ + pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ if (totalRemises > 0 || totalCadeaux > 0) ...[ @@ -1138,24 +1180,32 @@ Future _generateInvoice(Commande commande) async { mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Text('SOUS-TOTAL', style: normalTextStyle), - pw.SizedBox(width: 15), - pw.Text('${sousTotal.toStringAsFixed(0)}', style: normalTextStyle), + pw.SizedBox(width: 20), + pw.Container( + width: 80, + child: pw.Text('${sousTotal.toStringAsFixed(0)}', + style: normalTextStyle, textAlign: pw.TextAlign.right), + ), ], ), - pw.SizedBox(height: 3), + pw.SizedBox(height: 4), ], if (totalRemises > 0) ...[ pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ - pw.Text('REMISES TOTALES', style: pw.TextStyle(color: PdfColors.orange, fontSize: 8)), - pw.SizedBox(width: 15), - pw.Text('-${totalRemises.toStringAsFixed(0)}', - style: pw.TextStyle(color: PdfColors.orange, fontWeight: pw.FontWeight.bold, fontSize: 8)), + pw.Text('REMISES TOTALES', style: pw.TextStyle(color: PdfColors.orange, fontSize: 9)), + pw.SizedBox(width: 20), + pw.Container( + width: 80, + child: pw.Text('-${totalRemises.toStringAsFixed(0)}', + style: pw.TextStyle(color: PdfColors.orange, fontWeight: pw.FontWeight.bold, fontSize: 9), + textAlign: pw.TextAlign.right), + ), ], ), - pw.SizedBox(height: 3), + pw.SizedBox(height: 4), ], if (totalCadeaux > 0) ...[ @@ -1163,21 +1213,25 @@ Future _generateInvoice(Commande commande) async { mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Text('CADEAUX OFFERTS ($nombreCadeaux)', - style: pw.TextStyle(color: PdfColors.green700, fontSize: 8)), - pw.SizedBox(width: 15), - pw.Text('-${totalCadeaux.toStringAsFixed(0)}', - style: pw.TextStyle(color: PdfColors.green700, fontWeight: pw.FontWeight.bold, fontSize: 8)), + style: pw.TextStyle(color: PdfColors.green700, fontSize: 9)), + pw.SizedBox(width: 20), + pw.Container( + width: 80, + child: pw.Text('-${totalCadeaux.toStringAsFixed(0)}', + style: pw.TextStyle(color: PdfColors.green700, fontWeight: pw.FontWeight.bold, fontSize: 9), + textAlign: pw.TextAlign.right), + ), ], ), - pw.SizedBox(height: 3), + pw.SizedBox(height: 4), ], if (totalRemises > 0 || totalCadeaux > 0) ...[ pw.Container( - width: 150, + width: 200, height: 1, color: PdfColors.black, - margin: const pw.EdgeInsets.symmetric(vertical: 3), + margin: const pw.EdgeInsets.symmetric(vertical: 4), ), ], @@ -1185,17 +1239,21 @@ Future _generateInvoice(Commande commande) async { mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Text('TOTAL', style: boldTextStyle), - pw.SizedBox(width: 15), - pw.Text('${commande.montantTotal.toStringAsFixed(0)}', style: boldTextStyle), + pw.SizedBox(width: 20), + pw.Container( + width: 80, + child: pw.Text('${commande.montantTotal.toStringAsFixed(0)}', + style: boldTextStyle, textAlign: pw.TextAlign.right), + ), ], ), if (totalRemises > 0 || totalCadeaux > 0) ...[ - pw.SizedBox(height: 3), + pw.SizedBox(height: 4), pw.Text( 'Économies réalisées: ${(totalRemises + totalCadeaux).toStringAsFixed(0)} MGA', style: pw.TextStyle( - fontSize: 7, + fontSize: 8, color: PdfColors.green, fontStyle: pw.FontStyle.italic, ), @@ -1203,218 +1261,218 @@ Future _generateInvoice(Commande commande) async { ], ], ), + ], + ), + + pw.SizedBox(height: 15), + + // Montant en lettres + pw.Text('Arrêté à la somme de: ${_numberToWords(commande.montantTotal.toInt())} Ariary', + style: italicTextStyle), + + pw.SizedBox(height: 15), + + // Informations vendeurs - Section dédiée + pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.all(12), + decoration: pw.BoxDecoration( + color: PdfColors.grey100, + borderRadius: pw.BorderRadius.circular(8), + border: pw.Border.all(color: PdfColors.grey300), ), - - pw.SizedBox(width: 20), - - // Colonne droite - Informations vendeurs - pw.Expanded( - flex: 3, - child: pw.Container( - padding: const pw.EdgeInsets.all(8), - decoration: pw.BoxDecoration( - color: PdfColors.grey100, - borderRadius: pw.BorderRadius.circular(6), - border: pw.Border.all(color: PdfColors.grey300), + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'INFORMATIONS VENDEURS', + style: pw.TextStyle( + fontSize: 11, + fontWeight: pw.FontWeight.bold, + color: PdfColors.blue700, + ), ), - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, + pw.SizedBox(height: 8), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text( - 'INFORMATIONS VENDEURS', - style: pw.TextStyle( - fontSize: 9, - fontWeight: pw.FontWeight.bold, - color: PdfColors.blue700, + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'Vendeur initiateur:', + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700, + ), + ), + pw.SizedBox(height: 3), + pw.Text( + commandeur != null + ? '${commandeur.name} ${commandeur.lastName ?? ''}'.trim() + : 'Non spécifié', + style: pw.TextStyle( + fontSize: 10, + color: PdfColors.black, + ), + ), + pw.SizedBox(height: 3), + pw.Text( + 'Date: ${DateFormat('dd/MM/yyyy HH:mm').format(commande.dateCommande)}', + style: pw.TextStyle( + fontSize: 8, + color: PdfColors.grey600, + ), + ), + ], ), ), - pw.SizedBox(height: 6), - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Vendeur initiateur:', - style: pw.TextStyle( - fontSize: 7, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - pw.SizedBox(height: 1), - pw.Text( - commandeur != null - ? '${commandeur.name} ${commandeur.lastName ?? ''}'.trim() - : 'Non spécifié', - style: pw.TextStyle( - fontSize: 8, - color: PdfColors.black, - ), - ), - pw.SizedBox(height: 2), - pw.Text( - 'Date: ${DateFormat('dd/MM/yyyy HH:mm').format(commande.dateCommande)}', - style: pw.TextStyle( - fontSize: 6, - color: PdfColors.grey600, - ), - ), - ], + pw.Container( + width: 1, + height: 40, + color: PdfColors.grey400, + ), + pw.SizedBox(width: 20), + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Text( + 'Vendeur validateur:', + style: pw.TextStyle( + fontSize: 9, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey700, + ), ), - ), - pw.Container( - width: 1, - height: 30, - color: PdfColors.grey400, - ), - pw.SizedBox(width: 15), - pw.Expanded( - child: pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Vendeur validateur:', - style: pw.TextStyle( - fontSize: 7, - fontWeight: pw.FontWeight.bold, - color: PdfColors.grey700, - ), - ), - pw.SizedBox(height: 1), - pw.Text( - validateur != null - ? '${validateur.name} ${validateur.lastName ?? ''}'.trim() - : 'Non spécifié', - style: pw.TextStyle( - fontSize: 8, - color: PdfColors.black, - ), - ), - pw.SizedBox(height: 2), - pw.Text( - 'Date: ${DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now())}', - style: pw.TextStyle( - fontSize: 6, - color: PdfColors.grey600, - ), - ), - ], + pw.SizedBox(height: 3), + pw.Text( + validateur != null + ? '${validateur.name} ${validateur.lastName ?? ''}'.trim() + : 'Non spécifié', + style: pw.TextStyle( + fontSize: 10, + color: PdfColors.black, + ), ), - ), - ], + pw.SizedBox(height: 3), + pw.Text( + 'Date: ${DateFormat('dd/MM/yyyy HH:mm').format(DateTime.now())}', + style: pw.TextStyle( + fontSize: 8, + color: PdfColors.grey600, + ), + ), + ], + ), ), ], ), - ), - ), - ], - ), - - pw.SizedBox(height: 8), - - // Montant en lettres - pw.Text('Arrêté à la somme de: ${_numberToWords(commande.montantTotal.toInt())} Ariary', style: italicTextStyle), - - pw.SizedBox(height: 8), - - // Note de remerciement pour les cadeaux - compacte - if (totalCadeaux > 0) ...[ - pw.Container( - padding: const pw.EdgeInsets.all(6), - decoration: pw.BoxDecoration( - color: PdfColors.blue50, - borderRadius: pw.BorderRadius.circular(4), + ], ), - child: pw.Row( - children: [ - pw.Text('🎁 ', style: emojifont), - pw.Expanded( - child: pw.Text( - 'Merci de votre confiance ! Nous espérons que nos cadeaux vous feront plaisir. ($nombreCadeaux article(s) offert(s) - Valeur: ${totalCadeaux.toStringAsFixed(0)} MGA)', - style: pw.TextStyle( - fontSize: 7, - fontStyle: pw.FontStyle.italic, - color: PdfColors.blue700, + ), + + pw.SizedBox(height: 12), + + // Note de remerciement pour les cadeaux + if (totalCadeaux > 0) ...[ + pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.all(10), + decoration: pw.BoxDecoration( + color: PdfColors.blue50, + borderRadius: pw.BorderRadius.circular(6), + border: pw.Border.all(color: PdfColors.blue200), + ), + child: pw.Row( + children: [ + pw.Text('🎁 ', style: emojifont), + pw.Expanded( + child: pw.Text( + 'Merci de votre confiance ! Nous espérons que nos cadeaux vous feront plaisir. ($nombreCadeaux article(s) offert(s) - Valeur: ${totalCadeaux.toStringAsFixed(0)} MGA)', + style: pw.TextStyle( + fontSize: 9, + fontStyle: pw.FontStyle.italic, + color: PdfColors.blue700, + ), ), ), - ), - ], + ], + ), ), + pw.SizedBox(height: 12), + ], + + // Signatures - espacées sur toute la largeur + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text( + 'Signature vendeur initiateur', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 2), + pw.Text( + commandeur != null + ? '${commandeur.name} ${commandeur.lastName ?? ''}'.trim() + : 'Non spécifié', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + pw.SizedBox(height: 20), + pw.Container(width: 120, height: 1, color: PdfColors.black), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text( + 'Signature vendeur validateur', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 2), + pw.Text( + validateur != null + ? '${validateur.name} ${validateur.lastName ?? ''}'.trim() + : 'Non spécifié', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + pw.SizedBox(height: 20), + pw.Container(width: 120, height: 1, color: PdfColors.black), + ], + ), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.center, + children: [ + pw.Text( + 'Signature du client', + style: pw.TextStyle(fontSize: 9, fontWeight: pw.FontWeight.bold), + ), + pw.SizedBox(height: 2), + pw.Text( + client?.nomComplet ?? 'Non spécifié', + style: pw.TextStyle(fontSize: 8, color: PdfColors.grey600), + ), + pw.SizedBox(height: 20), + pw.Container(width: 120, height: 1, color: PdfColors.black), + ], + ), + ], ), - pw.SizedBox(height: 8), ], - - // Signatures - horizontales et compactes - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Signature vendeur initiateur', - style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold), - ), - pw.SizedBox(height: 1), - pw.Text( - commandeur != null - ? '${commandeur.name} ${commandeur.lastName ?? ''}'.trim() - : 'Non spécifié', - style: pw.TextStyle(fontSize: 6, color: PdfColors.grey600), - ), - pw.SizedBox(height: 15), - pw.Container(width: 100, height: 1, color: PdfColors.black), - ], - ), - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Signature vendeur validateur', - style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold), - ), - pw.SizedBox(height: 1), - pw.Text( - validateur != null - ? '${validateur.name} ${validateur.lastName ?? ''}'.trim() - : 'Non spécifié', - style: pw.TextStyle(fontSize: 6, color: PdfColors.grey600), - ), - pw.SizedBox(height: 15), - pw.Container(width: 100, height: 1, color: PdfColors.black), - ], - ), - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text( - 'Signature du client', - style: pw.TextStyle(fontSize: 7, fontWeight: pw.FontWeight.bold), - ), - pw.SizedBox(height: 1), - pw.Text( - client?.nomComplet ?? 'Non spécifié', - style: pw.TextStyle(fontSize: 6, color: PdfColors.grey600), - ), - pw.SizedBox(height: 15), - pw.Container(width: 100, height: 1, color: PdfColors.black), - ], - ), - ], - ), - ], - ); - }, - ), -); + ); + }, + ), + ); -final output = await getTemporaryDirectory(); -final file = File('${output.path}/facture_${commande.id}.pdf'); -await file.writeAsBytes(await pdf.save()); -await OpenFile.open(file.path); + final output = await getTemporaryDirectory(); + final file = File('${output.path}/facture_${commande.id}.pdf'); + await file.writeAsBytes(await pdf.save()); + await OpenFile.open(file.path); } String _numberToWords(int number) { diff --git a/lib/Views/demande_sortie_personnelle_page.dart b/lib/Views/demande_sortie_personnelle_page.dart new file mode 100644 index 0000000..e395637 --- /dev/null +++ b/lib/Views/demande_sortie_personnelle_page.dart @@ -0,0 +1,724 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:youmazgestion/Components/app_bar.dart'; +import 'package:youmazgestion/Components/appDrawer.dart'; +import 'package:youmazgestion/Services/stock_managementDatabase.dart'; +import 'package:youmazgestion/controller/userController.dart'; +import '../Models/produit.dart'; + +class DemandeSortiePersonnellePage extends StatefulWidget { + const DemandeSortiePersonnellePage({super.key}); + + @override + _DemandeSortiePersonnellePageState createState() => _DemandeSortiePersonnellePageState(); +} + +class _DemandeSortiePersonnellePageState extends State + with TickerProviderStateMixin { + final AppDatabase _database = AppDatabase.instance; + final UserController _userController = Get.find(); + + final _formKey = GlobalKey(); + final _quantiteController = TextEditingController(text: '1'); + final _motifController = TextEditingController(); + final _notesController = TextEditingController(); + final _searchController = TextEditingController(); + + Product? _selectedProduct; + List _products = []; + List _filteredProducts = []; + bool _isLoading = false; + bool _isSearching = false; + + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: this, + ); + _fadeAnimation = Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), + ); + _slideAnimation = Tween(begin: const Offset(0, 0.3), end: Offset.zero).animate( + CurvedAnimation(parent: _animationController, curve: Curves.easeOutCubic), + ); + + _loadProducts(); + _searchController.addListener(_filterProducts); + } + + void _filterProducts() { + final query = _searchController.text.toLowerCase(); + setState(() { + if (query.isEmpty) { + _filteredProducts = _products; + _isSearching = false; + } else { + _isSearching = true; + _filteredProducts = _products.where((product) { + return product.name.toLowerCase().contains(query) || + (product.reference?.toLowerCase().contains(query) ?? false); + }).toList(); + } + }); + } + + Future _loadProducts() async { + setState(() => _isLoading = true); + try { + final products = await _database.getProducts(); + setState(() { + _products = products.where((p) => (p.stock ?? 0) > 0).toList(); + _filteredProducts = _products; + _isLoading = false; + }); + _animationController.forward(); + } catch (e) { + setState(() => _isLoading = false); + _showErrorSnackbar('Impossible de charger les produits: $e'); + } + } + + Future _soumettreDemandePersonnelle() async { + if (!_formKey.currentState!.validate() || _selectedProduct == null) { + _showErrorSnackbar('Veuillez remplir tous les champs obligatoires'); + return; + } + + final quantite = int.tryParse(_quantiteController.text) ?? 0; + + if (quantite <= 0) { + _showErrorSnackbar('La quantité doit être supérieure à 0'); + return; + } + + if ((_selectedProduct!.stock ?? 0) < quantite) { + _showErrorSnackbar('Stock insuffisant (disponible: ${_selectedProduct!.stock})'); + return; + } + + // Confirmation dialog + final confirmed = await _showConfirmationDialog(); + if (!confirmed) return; + + setState(() => _isLoading = true); + + try { + await _database.createSortieStockPersonnelle( + produitId: _selectedProduct!.id!, + adminId: _userController.userId, + quantite: quantite, + motif: _motifController.text.trim(), + pointDeVenteId: _userController.pointDeVenteId > 0 ? _userController.pointDeVenteId : null, + notes: _notesController.text.trim().isNotEmpty ? _notesController.text.trim() : null, + ); + + _showSuccessSnackbar('Votre demande de sortie personnelle a été soumise pour approbation'); + + // Réinitialiser le formulaire avec animation + _resetForm(); + _loadProducts(); + } catch (e) { + _showErrorSnackbar('Impossible de soumettre la demande: $e'); + } finally { + setState(() => _isLoading = false); + } + } + + void _resetForm() { + _formKey.currentState!.reset(); + _quantiteController.text = '1'; + _motifController.clear(); + _notesController.clear(); + _searchController.clear(); + setState(() { + _selectedProduct = null; + _isSearching = false; + }); + } + + Future _showConfirmationDialog() async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + title: Row( + children: [ + Icon(Icons.help_outline, color: Colors.orange.shade700), + const SizedBox(width: 8), + const Text('Confirmer la demande'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Êtes-vous sûr de vouloir soumettre cette demande ?'), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Produit: ${_selectedProduct?.name}'), + Text('Quantité: ${_quantiteController.text}'), + Text('Motif: ${_motifController.text}'), + ], + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.orange.shade700, + foregroundColor: Colors.white, + ), + child: const Text('Confirmer'), + ), + ], + ), + ) ?? false; + } + + void _showSuccessSnackbar(String message) { + Get.snackbar( + '', + '', + titleText: Row( + children: [ + Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 8), + const Text('Succès', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ], + ), + messageText: Text(message, style: const TextStyle(color: Colors.white)), + backgroundColor: Colors.green.shade600, + colorText: Colors.white, + duration: const Duration(seconds: 4), + margin: const EdgeInsets.all(16), + borderRadius: 12, + icon: Icon(Icons.check_circle_outline, color: Colors.white), + ); + } + + void _showErrorSnackbar(String message) { + Get.snackbar( + '', + '', + titleText: Row( + children: [ + Icon(Icons.error, color: Colors.white), + const SizedBox(width: 8), + const Text('Erreur', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold)), + ], + ), + messageText: Text(message, style: const TextStyle(color: Colors.white)), + backgroundColor: Colors.red.shade600, + colorText: Colors.white, + duration: const Duration(seconds: 4), + margin: const EdgeInsets.all(16), + borderRadius: 12, + ); + } + + Widget _buildHeaderCard() { + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade600, Colors.blue.shade400], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.blue.shade200, + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon(Icons.inventory_2, color: Colors.white, size: 28), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Sortie personnelle de stock', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + const SizedBox(height: 4), + Text( + 'Demande d\'approbation requise', + style: TextStyle( + fontSize: 14, + color: Colors.white.withOpacity(0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Text( + 'Cette fonctionnalité permet aux administrateurs de demander ' + 'la sortie d\'un produit du stock pour usage personnel. ' + 'Toute demande nécessite une approbation avant traitement.', + style: TextStyle(fontSize: 14, color: Colors.white), + ), + ), + ], + ), + ); + } + + Widget _buildProductSelector() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Sélection du produit *', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 12), + + // Barre de recherche + Container( + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher un produit...', + prefixIcon: Icon(Icons.search, color: Colors.grey.shade600), + suffixIcon: _isSearching + ? IconButton( + icon: Icon(Icons.clear, color: Colors.grey.shade600), + onPressed: () { + _searchController.clear(); + FocusScope.of(context).unfocus(); + }, + ) + : null, + border: InputBorder.none, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ), + const SizedBox(height: 12), + + // Liste des produits + Container( + height: 200, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), + ), + child: _filteredProducts.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.search_off, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 8), + Text( + _isSearching ? 'Aucun produit trouvé' : 'Aucun produit disponible', + style: TextStyle(color: Colors.grey.shade600), + ), + ], + ), + ) + : ListView.builder( + itemCount: _filteredProducts.length, + itemBuilder: (context, index) { + final product = _filteredProducts[index]; + final isSelected = _selectedProduct?.id == product.id; + + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: isSelected ? Colors.orange.shade50 : Colors.transparent, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: isSelected ? Colors.orange.shade300 : Colors.transparent, + width: 2, + ), + ), + child: ListTile( + leading: Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: isSelected ? Colors.orange.shade100 : Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.inventory, + color: isSelected ? Colors.orange.shade700 : Colors.grey.shade600, + ), + ), + title: Text( + product.name, + style: TextStyle( + fontWeight: isSelected ? FontWeight.bold : FontWeight.w500, + color: isSelected ? Colors.orange.shade800 : Colors.grey.shade800, + ), + ), + subtitle: Text( + 'Stock: ${product.stock} • Réf: ${product.reference ?? 'N/A'}', + style: TextStyle( + color: isSelected ? Colors.orange.shade600 : Colors.grey.shade600, + ), + ), + trailing: isSelected + ? Icon(Icons.check_circle, color: Colors.orange.shade700) + : Icon(Icons.radio_button_unchecked, color: Colors.grey.shade400), + onTap: () { + setState(() { + _selectedProduct = product; + }); + }, + ), + ); + }, + ), + ), + ], + ); + } + + Widget _buildFormSection() { + return Column( + children: [ + // Quantité + _buildInputField( + label: 'Quantité *', + controller: _quantiteController, + keyboardType: TextInputType.number, + icon: Icons.format_list_numbered, + suffix: _selectedProduct != null + ? Text('max: ${_selectedProduct!.stock}', style: TextStyle(color: Colors.grey.shade600)) + : null, + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer une quantité'; + } + final quantite = int.tryParse(value); + if (quantite == null || quantite <= 0) { + return 'Quantité invalide'; + } + if (_selectedProduct != null && quantite > (_selectedProduct!.stock ?? 0)) { + return 'Quantité supérieure au stock disponible'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // Motif + _buildInputField( + label: 'Motif *', + controller: _motifController, + icon: Icons.description, + hintText: 'Raison de cette sortie personnelle', + validator: (value) { + if (value == null || value.trim().isEmpty) { + return 'Veuillez indiquer le motif'; + } + if (value.trim().length < 5) { + return 'Le motif doit contenir au moins 5 caractères'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // Notes + _buildInputField( + label: 'Notes complémentaires', + controller: _notesController, + icon: Icons.note_add, + hintText: 'Informations complémentaires (optionnel)', + maxLines: 3, + ), + ], + ); + } + + Widget _buildInputField({ + required String label, + required TextEditingController controller, + required IconData icon, + String? hintText, + TextInputType? keyboardType, + int maxLines = 1, + Widget? suffix, + String? Function(String?)? validator, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey.shade800, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + keyboardType: keyboardType, + maxLines: maxLines, + validator: validator, + decoration: InputDecoration( + hintText: hintText, + prefixIcon: Icon(icon, color: Colors.grey.shade600), + suffix: suffix, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.orange.shade400, width: 2), + ), + filled: true, + fillColor: Colors.grey.shade50, + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + ), + ), + ], + ); + } + + Widget _buildUserInfoCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.person, color: Colors.grey.shade700), + const SizedBox(width: 8), + Text( + 'Informations de la demande', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.grey.shade800, + ), + ), + ], + ), + const SizedBox(height: 12), + _buildInfoRow(Icons.account_circle, 'Demandeur', _userController.name), + if (_userController.pointDeVenteId > 0) + _buildInfoRow(Icons.store, 'Point de vente', _userController.pointDeVenteDesignation), + _buildInfoRow(Icons.calendar_today, 'Date', DateTime.now().toLocal().toString().split(' ')[0]), + ], + ), + ); + } + + Widget _buildInfoRow(IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + children: [ + Icon(icon, size: 16, color: Colors.grey.shade600), + const SizedBox(width: 8), + Text( + '$label: ', + style: TextStyle( + fontWeight: FontWeight.w500, + color: Colors.grey.shade700, + ), + ), + Expanded( + child: Text( + value, + style: TextStyle(color: Colors.grey.shade800), + ), + ), + ], + ), + ); + } + + Widget _buildSubmitButton() { + return Container( + width: double.infinity, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + gradient: LinearGradient( + colors: [Colors.orange.shade700, Colors.orange.shade500], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: Colors.orange.shade300, + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: ElevatedButton( + onPressed: _isLoading ? null : _soumettreDemandePersonnelle, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + child: _isLoading + ? const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + SizedBox(width: 12), + Text( + 'Traitement...', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.send, color: Colors.white), + SizedBox(width: 8), + Text( + 'Soumettre la demande', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar(title: 'Demande sortie personnelle'), + drawer: CustomDrawer(), + body: _isLoading && _products.isEmpty + ? const Center(child: CircularProgressIndicator()) + : FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderCard(), + const SizedBox(height: 24), + _buildProductSelector(), + const SizedBox(height: 24), + _buildFormSection(), + const SizedBox(height: 24), + _buildUserInfoCard(), + const SizedBox(height: 32), + _buildSubmitButton(), + const SizedBox(height: 16), + ], + ), + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _animationController.dispose(); + _quantiteController.dispose(); + _motifController.dispose(); + _notesController.dispose(); + _searchController.dispose(); + super.dispose(); + } +} \ No newline at end of file diff --git a/lib/Views/editUser.dart b/lib/Views/editUser.dart index d6c7ab3..f373695 100644 --- a/lib/Views/editUser.dart +++ b/lib/Views/editUser.dart @@ -2,7 +2,6 @@ import 'package:flutter/material.dart'; import 'package:youmazgestion/Models/users.dart'; import 'package:youmazgestion/Models/role.dart'; import 'package:youmazgestion/Services/stock_managementDatabase.dart'; -//import '../Services/app_database.dart'; class EditUserPage extends StatefulWidget { final Users user; @@ -21,9 +20,11 @@ class _EditUserPageState extends State { late TextEditingController _passwordController; List _roles = []; + List> _pointsDeVente = []; Role? _selectedRole; + Map? _selectedPointDeVente; bool _isLoading = false; - bool _isLoadingRoles = true; + bool _isLoadingData = true; @override void initState() { @@ -34,28 +35,47 @@ class _EditUserPageState extends State { _usernameController = TextEditingController(text: widget.user.username); _passwordController = TextEditingController(); - _loadRoles(); + _loadInitialData(); } - Future _loadRoles() async { + Future _loadInitialData() async { try { + // Charger les rôles final roles = await AppDatabase.instance.getRoles(); final currentRole = roles.firstWhere( (r) => r.id == widget.user.roleId, orElse: () => Role(id: widget.user.roleId, designation: widget.user.roleName ?? 'Inconnu'), ); + // Charger les points de vente + final pointsDeVente = await AppDatabase.instance.getPointsDeVente(); + + // Trouver le point de vente actuel de l'utilisateur + Map? currentPointDeVente; + if (widget.user.pointDeVenteId != null) { + try { + currentPointDeVente = pointsDeVente.firstWhere( + (pv) => pv['id'] == widget.user.pointDeVenteId, + ); + } catch (e) { + // Point de vente non trouvé, on garde null + print('Point de vente ${widget.user.pointDeVenteId} non trouvé'); + } + } + setState(() { _roles = roles; _selectedRole = currentRole; - _isLoadingRoles = false; + _pointsDeVente = pointsDeVente; + _selectedPointDeVente = currentPointDeVente; + _isLoadingData = false; }); } catch (e) { - print('Erreur lors du chargement des rôles: $e'); + print('Erreur lors du chargement des données: $e'); setState(() { - _isLoadingRoles = false; + _isLoadingData = false; }); - _showErrorDialog('Erreur', 'Impossible de charger les rôles.'); + _showErrorDialog('Erreur', 'Impossible de charger les données nécessaires.'); } } @@ -69,13 +89,30 @@ class _EditUserPageState extends State { super.dispose(); } + // ✅ AMÉLIORÉ: Validation des champs avec messages plus précis bool _validateFields() { - if (_nameController.text.trim().isEmpty || - _lastNameController.text.trim().isEmpty || - _emailController.text.trim().isEmpty || - _usernameController.text.trim().isEmpty || - _selectedRole == null) { - _showErrorDialog('Champs manquants', 'Veuillez remplir tous les champs requis.'); + if (_nameController.text.trim().isEmpty) { + _showErrorDialog('Champ manquant', 'Le prénom est requis.'); + return false; + } + + if (_lastNameController.text.trim().isEmpty) { + _showErrorDialog('Champ manquant', 'Le nom de famille est requis.'); + return false; + } + + if (_emailController.text.trim().isEmpty) { + _showErrorDialog('Champ manquant', 'L\'email est requis.'); + return false; + } + + if (_usernameController.text.trim().isEmpty) { + _showErrorDialog('Champ manquant', 'Le nom d\'utilisateur est requis.'); + return false; + } + + if (_selectedRole == null) { + _showErrorDialog('Champ manquant', 'Veuillez sélectionner un rôle.'); return false; } @@ -87,13 +124,19 @@ class _EditUserPageState extends State { if (_passwordController.text.isNotEmpty && _passwordController.text.length < 6) { - _showErrorDialog('Mot de passe trop court', 'Minimum 6 caractères.'); + _showErrorDialog('Mot de passe trop court', 'Le mot de passe doit contenir au minimum 6 caractères.'); + return false; + } + + if (_usernameController.text.trim().length < 3) { + _showErrorDialog('Nom d\'utilisateur trop court', 'Le nom d\'utilisateur doit contenir au minimum 3 caractères.'); return false; } return true; } + // ✅ CORRIGÉ: Méthode _updateUser avec validation et gestion d'erreurs améliorée Future _updateUser() async { if (!_validateFields() || _isLoading) return; @@ -102,6 +145,9 @@ class _EditUserPageState extends State { }); try { + print("🔄 Début de la mise à jour utilisateur..."); + + // ✅ Créer l'objet utilisateur avec toutes les données final updatedUser = Users( id: widget.user.id, name: _nameController.text.trim(), @@ -113,14 +159,69 @@ class _EditUserPageState extends State { : widget.user.password, roleId: _selectedRole!.id!, roleName: _selectedRole!.designation, + pointDeVenteId: _selectedPointDeVente?['id'], ); - await AppDatabase.instance.updateUser(updatedUser); - if (mounted) _showSuccessDialog(); + print("📝 Données utilisateur à mettre à jour:"); + print(" ID: ${updatedUser.id}"); + print(" Nom: ${updatedUser.name} ${updatedUser.lastName}"); + print(" Email: ${updatedUser.email}"); + print(" Username: ${updatedUser.username}"); + print(" Role ID: ${updatedUser.roleId}"); + print(" Point de vente ID: ${updatedUser.pointDeVenteId}"); + + // ✅ Validation avant mise à jour + final validationError = await AppDatabase.instance.validateUserUpdate(updatedUser); + if (validationError != null) { + if (mounted) { + _showErrorDialog('Erreur de validation', validationError); + } + return; + } + + // ✅ Vérifier que l'utilisateur existe + final userExists = await AppDatabase.instance.userExists(widget.user.id!); + if (!userExists) { + if (mounted) { + _showErrorDialog('Erreur', 'L\'utilisateur à modifier n\'existe plus dans la base de données.'); + } + return; + } + + // ✅ Effectuer la mise à jour + final affectedRows = await AppDatabase.instance.updateUser(updatedUser); + + if (affectedRows > 0) { + print("✅ Utilisateur mis à jour avec succès!"); + if (mounted) _showSuccessDialog(); + } else { + print("⚠️ Aucune ligne affectée lors de la mise à jour"); + if (mounted) { + _showErrorDialog('Information', 'Aucune modification n\'a été effectuée.'); + } + } + } catch (e) { - print('Erreur de mise à jour: $e'); + print('❌ Erreur de mise à jour: $e'); if (mounted) { - _showErrorDialog('Échec', 'Une erreur est survenue lors de la mise à jour.'); + String errorMessage = 'Une erreur est survenue lors de la mise à jour.'; + + // Messages d'erreur plus spécifiques + if (e.toString().contains('Duplicate entry')) { + if (e.toString().contains('email')) { + errorMessage = 'Cet email est déjà utilisé par un autre utilisateur.'; + } else if (e.toString().contains('username')) { + errorMessage = 'Ce nom d\'utilisateur est déjà utilisé.'; + } else { + errorMessage = 'Ces informations sont déjà utilisées par un autre utilisateur.'; + } + } else if (e.toString().contains('Cannot add or update a child row')) { + errorMessage = 'Le rôle ou le point de vente sélectionné n\'existe pas.'; + } else if (e.toString().contains('Connection')) { + errorMessage = 'Erreur de connexion à la base de données. Veuillez réessayer.'; + } + + _showErrorDialog('Échec', errorMessage); } } finally { if (mounted) { @@ -130,16 +231,30 @@ class _EditUserPageState extends State { } } } + void _showSuccessDialog() { showDialog( context: context, builder: (context) => AlertDialog( - title: const Text('Mise à jour réussie'), - content: const Text('Les informations de l\'utilisateur ont été mises à jour.'), + title: Row( + children: const [ + Icon(Icons.check_circle, color: Colors.green), + SizedBox(width: 8), + Text('Mise à jour réussie'), + ], + ), + content: const Text('Les informations de l\'utilisateur ont été mises à jour avec succès.'), actions: [ ElevatedButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + Navigator.of(context).pop(); // Fermer le dialog + Navigator.of(context).pop(); // Retourner à la page précédente + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), child: const Text('OK'), ) ], @@ -151,11 +266,21 @@ class _EditUserPageState extends State { showDialog( context: context, builder: (context) => AlertDialog( - title: Text(title), + title: Row( + children: [ + const Icon(Icons.error, color: Colors.red), + const SizedBox(width: 8), + Text(title), + ], + ), content: Text(message), actions: [ ElevatedButton( onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), child: const Text('OK'), ) ], @@ -172,50 +297,144 @@ class _EditUserPageState extends State { iconTheme: const IconThemeData(color: Colors.white), centerTitle: true, ), - body: _isLoadingRoles - ? const Center(child: CircularProgressIndicator()) + body: _isLoadingData + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Chargement des données...'), + ], + ), + ) : SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Card( elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), child: Padding( - padding: const EdgeInsets.all(16.0), + padding: const EdgeInsets.all(20.0), child: Column( children: [ - const Icon(Icons.edit, size: 64, color: Colors.blue), - const SizedBox(height: 16), + // En-tête + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.edit, size: 32, color: Colors.blue), + const SizedBox(width: 12), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Modification d\'utilisateur', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Colors.blue, + ), + ), + Text( + 'ID: ${widget.user.id}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ], + ), + ), + + const SizedBox(height: 24), + + // Informations personnelles + _buildSectionTitle('Informations personnelles'), + const SizedBox(height: 12), _buildTextField(_nameController, 'Prénom', Icons.person), const SizedBox(height: 12), _buildTextField(_lastNameController, 'Nom', Icons.person_outline), const SizedBox(height: 12), _buildTextField(_emailController, 'Email', Icons.email, keyboardType: TextInputType.emailAddress), + + const SizedBox(height: 24), + + // Informations de connexion + _buildSectionTitle('Informations de connexion'), const SizedBox(height: 12), _buildTextField(_usernameController, 'Nom d\'utilisateur', Icons.account_circle), const SizedBox(height: 12), _buildTextField( _passwordController, - 'Mot de passe (laisser vide si inchangé)', + 'Nouveau mot de passe (optionnel)', Icons.lock, obscureText: true, ), + + const SizedBox(height: 24), + + // Permissions et affectation + _buildSectionTitle('Permissions et affectation'), const SizedBox(height: 12), - _buildDropdown(), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - onPressed: _isLoading ? null : _updateUser, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF0015B7), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + _buildRoleDropdown(), + const SizedBox(height: 12), + _buildPointDeVenteDropdown(), + + const SizedBox(height: 32), + + // Boutons d'action + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: _isLoading ? null : () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + side: const BorderSide(color: Colors.grey), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: const Text('Annuler'), ), ), - child: _isLoading - ? const CircularProgressIndicator(color: Colors.white) - : const Text('Mettre à jour', style: TextStyle(color: Colors.white, fontSize: 16)), - ), + const SizedBox(width: 16), + Expanded( + flex: 2, + child: ElevatedButton( + onPressed: _isLoading ? null : _updateUser, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF0015B7), + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : const Text( + 'Mettre à jour', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + ), + ), + ), + ], ) ], ), @@ -225,6 +444,26 @@ class _EditUserPageState extends State { ); } + Widget _buildSectionTitle(String title) { + return Container( + width: double.infinity, + padding: const EdgeInsets.only(bottom: 8), + decoration: const BoxDecoration( + border: Border( + bottom: BorderSide(color: Colors.grey, width: 0.5), + ), + ), + child: Text( + title, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 4, 54, 95), + ), + ), + ); + } + Widget _buildTextField( TextEditingController controller, String label, @@ -236,21 +475,31 @@ class _EditUserPageState extends State { controller: controller, decoration: InputDecoration( labelText: label, - prefixIcon: Icon(icon), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + prefixIcon: Icon(icon, color: Colors.grey.shade600), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: Colors.grey.shade300), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: Color(0xFF0015B7), width: 2), + ), + filled: true, + fillColor: Colors.grey.shade50, ), keyboardType: keyboardType, obscureText: obscureText, ); } - Widget _buildDropdown() { + Widget _buildRoleDropdown() { return Container( width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - border: Border.all(color: Colors.grey), + border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, ), child: DropdownButtonHideUnderline( child: DropdownButton( @@ -269,7 +518,7 @@ class _EditUserPageState extends State { value: role, child: Row( children: [ - const Icon(Icons.badge, size: 20), + Icon(Icons.badge, size: 20, color: Colors.grey.shade600), const SizedBox(width: 8), Text(role.designation), ], @@ -280,4 +529,72 @@ class _EditUserPageState extends State { ), ); } -} + + Widget _buildPointDeVenteDropdown() { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: DropdownButtonHideUnderline( + child: DropdownButton?>( + value: _selectedPointDeVente, + isExpanded: true, + hint: const Text('Sélectionner un point de vente (optionnel)'), + onChanged: _isLoading + ? null + : (Map? newValue) { + setState(() { + _selectedPointDeVente = newValue; + }); + }, + items: [ + // Option "Aucun point de vente" + const DropdownMenuItem?>( + value: null, + child: Row( + children: [ + Icon(Icons.not_interested, size: 20, color: Colors.grey), + SizedBox(width: 8), + Text('Aucun point de vente'), + ], + ), + ), + // Points de vente disponibles + ..._pointsDeVente.map((pointDeVente) { + return DropdownMenuItem>( + value: pointDeVente, + child: Row( + children: [ + Icon(Icons.store, size: 20, color: Colors.grey.shade600), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(pointDeVente['nom'] ?? 'N/A'), + if (pointDeVente['code'] != null) + Text( + 'Code: ${pointDeVente['code']}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/Views/gestion_point_de_vente.dart b/lib/Views/gestion_point_de_vente.dart index e15ad18..f69f6ad 100644 --- a/lib/Views/gestion_point_de_vente.dart +++ b/lib/Views/gestion_point_de_vente.dart @@ -31,30 +31,38 @@ class _AjoutPointDeVentePageState extends State { _searchController.addListener(_filterPointsDeVente); } - Future _loadPointsDeVente() async { - setState(() { - _isLoading = true; - }); +Future _loadPointsDeVente() async { + setState(() { + _isLoading = true; + }); + + try { + final points = await _appDatabase.getPointsDeVente(); - try { - final points = await _appDatabase.getPointsDeVente(); - setState(() { - _pointsDeVente = points; - _isLoading = false; - }); - } catch (e) { - setState(() { - _isLoading = false; - }); - Get.snackbar( - 'Erreur', - 'Impossible de charger les points de vente: ${e.toString()}', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, - ); + // Enrichir chaque point de vente avec les informations de contraintes + for (var point in points) { + final verification = await _appDatabase.checkCanDeletePointDeVente(point['id']); + point['canDelete'] = verification['canDelete']; + point['constraintCount'] = (verification['reasons'] as List).length; } + + setState(() { + _pointsDeVente = points; + _isLoading = false; + }); + } catch (e) { + setState(() { + _isLoading = false; + }); + Get.snackbar( + 'Erreur', + 'Impossible de charger les points de vente: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); } +} void _filterPointsDeVente() { final query = _searchController.text.toLowerCase(); @@ -112,56 +120,357 @@ class _AjoutPointDeVentePageState extends State { } } } +Future _showConstraintDialog(int id, Map verificationResult) async { + final reasons = verificationResult['reasons'] as List; + final suggestions = verificationResult['suggestions'] as List; + + await Get.dialog( + AlertDialog( + title: Row( + children: const [ + Icon(Icons.warning, color: Colors.orange), + SizedBox(width: 8), + Text('Suppression impossible'), + ], + ), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + 'Ce point de vente ne peut pas être supprimé pour les raisons suivantes :', + style: TextStyle(fontWeight: FontWeight.bold), + ), + const SizedBox(height: 12), + ...reasons.map((reason) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('• ', style: TextStyle(color: Colors.red)), + Expanded(child: Text(reason)), + ], + ), + )), + if (suggestions.isNotEmpty) ...[ + const SizedBox(height: 16), + const Text( + 'Solutions possibles :', + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blue), + ), + const SizedBox(height: 8), + ...suggestions.map((suggestion) => Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('💡 ', style: TextStyle(fontSize: 12)), + Expanded(child: Text(suggestion, style: const TextStyle(fontSize: 13))), + ], + ), + )), + ], + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Fermer'), + ), + if (reasons.any((r) => r.contains('produit'))) + ElevatedButton( + onPressed: () { + Get.back(); + _showTransferDialog(id); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: const Text('Transférer les produits'), + ), + ], + ), + ); +} +Future _showTransferDialog(int sourcePointDeVenteId) async { + final pointsDeVente = await _appDatabase.getPointsDeVenteForTransfer(sourcePointDeVenteId); + + if (pointsDeVente.isEmpty) { + Get.snackbar( + 'Erreur', + 'Aucun autre point de vente disponible pour le transfert', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + int? selectedPointDeVenteId; + + await Get.dialog( + AlertDialog( + title: const Text('Transférer les produits'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Sélectionnez le point de vente de destination pour les produits :'), + const SizedBox(height: 16), + SizedBox( + width: double.maxFinite, + child: DropdownButtonFormField( + value: selectedPointDeVenteId, + decoration: const InputDecoration( + labelText: 'Point de vente de destination', + border: OutlineInputBorder(), + ), + items: pointsDeVente.map((pv) => DropdownMenuItem( + value: pv['id'] as int, + child: Text(pv['nom'] as String), + )).toList(), + onChanged: (value) { + selectedPointDeVenteId = value; + }, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Annuler'), + ), + ElevatedButton( + onPressed: () async { + if (selectedPointDeVenteId != null) { + Get.back(); + await _performTransferAndDelete(sourcePointDeVenteId, selectedPointDeVenteId!); + } else { + Get.snackbar( + 'Erreur', + 'Veuillez sélectionner un point de vente de destination', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + foregroundColor: Colors.white, + ), + child: const Text('Transférer et supprimer'), + ), + ], + ), + ); +} +// Nouvelle méthode pour effectuer le transfert et la suppression +Future _performTransferAndDelete(int sourceId, int targetId) async { + setState(() { + _isLoading = true; + }); - Future _deletePointDeVente(int id) async { + try { + // Afficher un dialog de confirmation final final confirmed = await Get.dialog( AlertDialog( - title: const Text('Confirmer la suppression'), - content: const Text('Voulez-vous vraiment supprimer ce point de vente ?'), + title: const Text('Confirmation finale'), + content: const Text( + 'Cette action va transférer tous les produits vers le point de vente sélectionné ' + 'puis supprimer définitivement le point de vente original. ' + 'Cette action est irréversible. Continuer ?' + ), actions: [ TextButton( onPressed: () => Get.back(result: false), child: const Text('Annuler'), ), - TextButton( + ElevatedButton( onPressed: () => Get.back(result: true), - child: const Text('Supprimer', style: TextStyle(color: Colors.red)), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text('Confirmer'), ), ], ), ); if (confirmed == true) { + await _appDatabase.deletePointDeVenteWithTransfer(sourceId, targetId); + await _loadPointsDeVente(); + + Get.snackbar( + 'Succès', + 'Produits transférés et point de vente supprimé avec succès', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 4), + ); + } + } catch (e) { + Get.snackbar( + 'Erreur', + 'Erreur lors du transfert: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { + setState(() { + _isLoading = false; + }); + } +} +// Vous pouvez aussi ajouter une méthode pour voir les détails d'un point de vente +Future _showPointDeVenteDetails(Map pointDeVente) async { + final id = pointDeVente['id'] as int; + + try { + // Récupérer les statistiques + final stats = await _getPointDeVenteStats(id); + + await Get.dialog( + AlertDialog( + title: Text('Détails: ${pointDeVente['nom']}'), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildStatRow('Produits associés', '${stats['produits']}'), + _buildStatRow('Utilisateurs associés', '${stats['utilisateurs']}'), + _buildStatRow('Demandes de transfert', '${stats['transferts']}'), + const SizedBox(height: 8), + Text( + 'Code: ${pointDeVente['code'] ?? 'N/A'}', + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Fermer'), + ), + ], + ), + ); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible de récupérer les détails: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } +} + +Widget _buildStatRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label), + Text(value, style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ); +} + +// Méthode helper pour récupérer les stats +Future> _getPointDeVenteStats(int id) async { + final verification = await _appDatabase.checkCanDeletePointDeVente(id); + + // Parser les raisons pour extraire les nombres + int produits = 0, utilisateurs = 0, transferts = 0; + + for (String reason in verification['reasons']) { + if (reason.contains('produit')) { + produits = int.tryParse(reason.split(' ')[0]) ?? 0; + } else if (reason.contains('utilisateur')) { + utilisateurs = int.tryParse(reason.split(' ')[0]) ?? 0; + } else if (reason.contains('transfert')) { + transferts = int.tryParse(reason.split(' ')[0]) ?? 0; + } + } + + return { + 'produits': produits, + 'utilisateurs': utilisateurs, + 'transferts': transferts, + }; +} + + Future _deletePointDeVente(int id) async { + // 1. D'abord vérifier si la suppression est possible + final verificationResult = await _appDatabase.checkCanDeletePointDeVente(id); + + if (!verificationResult['canDelete']) { + // Afficher un dialog avec les détails des contraintes + await _showConstraintDialog(id, verificationResult); + return; + } + + // 2. Si pas de contraintes, procéder normalement + final confirmed = await Get.dialog( + AlertDialog( + title: const Text('Confirmer la suppression'), + content: const Text('Voulez-vous vraiment supprimer ce point de vente ?'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Annuler'), + ), + TextButton( + onPressed: () => Get.back(result: true), + child: const Text('Supprimer', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed == true) { + setState(() { + _isLoading = true; + }); + + try { + await _appDatabase.deletePointDeVente(id); + await _loadPointsDeVente(); + + Get.snackbar( + 'Succès', + 'Point de vente supprimé avec succès', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + ); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible de supprimer le point de vente: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } finally { setState(() { - _isLoading = true; + _isLoading = false; }); - - try { - await _appDatabase.deletePointDeVente(id); - await _loadPointsDeVente(); - - Get.snackbar( - 'Succès', - 'Point de vente supprimé avec succès', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.green, - colorText: Colors.white, - ); - } catch (e) { - Get.snackbar( - 'Erreur', - 'Impossible de supprimer le point de vente: ${e.toString()}', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, - ); - } finally { - setState(() { - _isLoading = false; - }); - } } } +} @override Widget build(BuildContext context) { @@ -354,48 +663,154 @@ class _AjoutPointDeVentePageState extends State { ), ) : ListView.builder( - itemCount: _pointsDeVente.length, - itemBuilder: (context, index) { - final point = _pointsDeVente[index]; - return Card( - margin: const EdgeInsets.only(bottom: 8), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - Expanded( - flex: 2, - child: Text( - point['nom'] ?? 'N/A', - style: const TextStyle( - fontWeight: FontWeight.w500), - ), - ), - Expanded( - child: Text( - point['code'] ?? 'N/A', - style: TextStyle( - color: Colors.grey.shade600), - ), - ), - SizedBox( - width: 40, - child: IconButton( - icon: const Icon(Icons.delete, - size: 20, color: Colors.red), - onPressed: () => _deletePointDeVente(point['id']), - ), - ), - ], - ), - ), - ); - }, - ), + itemCount: _pointsDeVente.length, + itemBuilder: (context, index) { + final point = _pointsDeVente[index]; + final canDelete = point['canDelete'] ?? true; + final constraintCount = point['constraintCount'] ?? 0; + + return Card( + margin: const EdgeInsets.only(bottom: 8), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: !canDelete ? Border.all( + color: Colors.orange.shade300, + width: 1, + ) : null, + ), + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + // Icône avec indicateur de statut + Stack( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: canDelete ? Colors.blue.shade50 : Colors.orange.shade50, + borderRadius: BorderRadius.circular(6), + ), + child: Icon( + Icons.store, + color: canDelete ? Colors.blue.shade700 : Colors.orange.shade700, + size: 20, + ), + ), + if (!canDelete) + Positioned( + right: 0, + top: 0, + child: Container( + padding: const EdgeInsets.all(2), + decoration: const BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + ), + child: Text( + '$constraintCount', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), + ), + ], + ), + const SizedBox(width: 12), + + // Informations du point de vente + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + point['nom'] ?? 'N/A', + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 15, + ), + ), + ), + if (!canDelete) + Icon( + Icons.link, + size: 16, + color: Colors.orange.shade600, + ), + ], + ), + Row( + children: [ + if (point['code'] != null && point['code'].toString().isNotEmpty) + Text( + 'Code: ${point['code']}', + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 12, + ), + ), + if (!canDelete) ...[ + const SizedBox(width: 8), + Text( + '$constraintCount contrainte(s)', + style: TextStyle( + color: Colors.orange.shade600, + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ], + ), + ), + + // Boutons d'actions + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bouton détails + IconButton( + icon: Icon( + Icons.info_outline, + size: 20, + color: Colors.blue.shade600, + ), + onPressed: () => _showPointDeVenteDetails(point), + tooltip: 'Voir les détails', + ), + + // Bouton suppression avec indication visuelle + IconButton( + icon: Icon( + canDelete ? Icons.delete_outline : Icons.delete_forever_outlined, + size: 20, + color: canDelete ? Colors.red : Colors.orange.shade700, + ), + onPressed: () => _deletePointDeVente(point['id']), + tooltip: canDelete ? 'Supprimer' : 'Supprimer (avec contraintes)', + ), + ], + ), + ], + ), + ), + ), + ); + }, +) ), ], ), diff --git a/lib/Views/historique_sorties_personnelles_page.dart b/lib/Views/historique_sorties_personnelles_page.dart new file mode 100644 index 0000000..fc29549 --- /dev/null +++ b/lib/Views/historique_sorties_personnelles_page.dart @@ -0,0 +1,354 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:youmazgestion/Components/app_bar.dart'; +import 'package:youmazgestion/Components/appDrawer.dart'; +import 'package:youmazgestion/Services/stock_managementDatabase.dart'; +import 'package:youmazgestion/controller/userController.dart'; + +class HistoriqueSortiesPersonnellesPage extends StatefulWidget { + const HistoriqueSortiesPersonnellesPage({super.key}); + + @override + _HistoriqueSortiesPersonnellesPageState createState() => _HistoriqueSortiesPersonnellesPageState(); +} + +class _HistoriqueSortiesPersonnellesPageState extends State { + final AppDatabase _database = AppDatabase.instance; + final UserController _userController = Get.find(); + + List> _historique = []; + String? _filtreStatut; + bool _isLoading = false; + bool _afficherSeulementMesDemandes = false; + + @override + void initState() { + super.initState(); + _loadHistorique(); + } + + Future _loadHistorique() async { + setState(() => _isLoading = true); + try { + final historique = await _database.getHistoriqueSortiesPersonnelles( + adminId: _afficherSeulementMesDemandes ? _userController.userId : null, + statut: _filtreStatut, + limit: 100, + ); + setState(() { + _historique = historique; + _isLoading = false; + }); + } catch (e) { + setState(() => _isLoading = false); + Get.snackbar('Erreur', 'Impossible de charger l\'historique: $e'); + } + } + + Color _getStatutColor(String statut) { + switch (statut) { + case 'en_attente': + return Colors.orange; + case 'approuvee': + return Colors.green; + case 'refusee': + return Colors.red; + default: + return Colors.grey; + } + } + + String _getStatutText(String statut) { + switch (statut) { + case 'en_attente': + return 'En attente'; + case 'approuvee': + return 'Approuvée'; + case 'refusee': + return 'Refusée'; + default: + return statut; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar(title: 'Historique sorties personnelles'), + drawer: CustomDrawer(), + body: Column( + children: [ + // Filtres + Container( + padding: const EdgeInsets.all(16), + color: Colors.grey.shade50, + child: Column( + children: [ + Row( + children: [ + Expanded( + child: DropdownButtonFormField( + value: _filtreStatut, + decoration: const InputDecoration( + labelText: 'Filtrer par statut', + border: OutlineInputBorder(), + isDense: true, + ), + items: const [ + DropdownMenuItem(value: null, child: Text('Tous les statuts')), + DropdownMenuItem(value: 'en_attente', child: Text('En attente')), + DropdownMenuItem(value: 'approuvee', child: Text('Approuvées')), + DropdownMenuItem(value: 'refusee', child: Text('Refusées')), + ], + onChanged: (value) { + setState(() { + _filtreStatut = value; + }); + _loadHistorique(); + }, + ), + ), + const SizedBox(width: 12), + ElevatedButton.icon( + onPressed: _loadHistorique, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Actualiser'), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Checkbox( + value: _afficherSeulementMesDemandes, + onChanged: (value) { + setState(() { + _afficherSeulementMesDemandes = value ?? false; + }); + _loadHistorique(); + }, + ), + const Text('Afficher seulement mes demandes'), + ], + ), + ], + ), + ), + + // Liste + Expanded( + child: _isLoading + ? const Center(child: CircularProgressIndicator()) + : RefreshIndicator( + onRefresh: _loadHistorique, + child: _historique.isEmpty + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text( + 'Aucun historique trouvé', + style: TextStyle(fontSize: 18, color: Colors.grey), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _historique.length, + itemBuilder: (context, index) { + final sortie = _historique[index]; + return _buildHistoriqueCard(sortie); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildHistoriqueCard(Map sortie) { + final dateSortie = DateTime.parse(sortie['date_sortie'].toString()); + final statut = sortie['statut'].toString(); + final statutColor = _getStatutColor(statut); + + return Card( + margin: const EdgeInsets.only(bottom: 12), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: statutColor.withOpacity(0.3), width: 1), + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avec statut et date + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: statutColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: statutColor.withOpacity(0.5)), + ), + child: Text( + _getStatutText(statut), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: statutColor, + ), + ), + ), + const Spacer(), + Text( + DateFormat('dd/MM/yyyy HH:mm').format(dateSortie), + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + const SizedBox(height: 12), + + // Informations principales + Row( + children: [ + Expanded( + flex: 2, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + sortie['produit_nom'].toString(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + Text('Réf: ${sortie['produit_reference'] ?? 'N/A'}'), + Text('Quantité: ${sortie['quantite']}'), + ], + ), + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Demandeur:', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + Text( + '${sortie['admin_nom']} ${sortie['admin_nom_famille'] ?? ''}', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + + // Motif + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Motif:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + Text(sortie['motif'].toString()), + ], + ), + ), + + // Informations d'approbation/refus + if (statut != 'en_attente' && sortie['approbateur_nom'] != null) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: statutColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + statut == 'approuvee' ? 'Approuvé par:' : 'Refusé par:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + Text('${sortie['approbateur_nom']} ${sortie['approbateur_nom_famille'] ?? ''}'), + if (sortie['date_approbation'] != null) + Text( + 'Le ${DateFormat('dd/MM/yyyy HH:mm').format(DateTime.parse(sortie['date_approbation'].toString()))}', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ], + + // Notes supplémentaires + if (sortie['notes'] != null && sortie['notes'].toString().isNotEmpty) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Notes:', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blue.shade700, + ), + ), + Text( + sortie['notes'].toString(), + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ], + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/Views/listUser.dart b/lib/Views/listUser.dart index e3392b6..d8194f0 100644 --- a/lib/Views/listUser.dart +++ b/lib/Views/listUser.dart @@ -3,7 +3,6 @@ import 'package:get/get.dart'; import 'package:youmazgestion/Models/users.dart'; import 'package:youmazgestion/Services/stock_managementDatabase.dart'; import '../Components/app_bar.dart'; -//import '../Services/app_database.dart'; import 'editUser.dart'; class ListUserPage extends StatefulWidget { @@ -15,112 +14,654 @@ class ListUserPage extends StatefulWidget { class _ListUserPageState extends State { List userList = []; + List filteredUserList = []; + List> pointsDeVente = []; + bool isLoading = true; + String searchQuery = ''; + int? selectedPointDeVenteFilter; + + final TextEditingController _searchController = TextEditingController(); @override void initState() { super.initState(); - getUsersFromDatabase(); + _loadData(); + _searchController.addListener(_filterUsers); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); } - Future getUsersFromDatabase() async { + Future _loadData() async { + setState(() { + isLoading = true; + }); + try { - List users = await AppDatabase.instance.getAllUsers(); + // Charger les utilisateurs et points de vente en parallèle + final futures = await Future.wait([ + AppDatabase.instance.getAllUsers(), + AppDatabase.instance.getPointsDeVente(), + ]); + + final users = futures[0] as List; + final points = futures[1] as List>; + setState(() { userList = users; + filteredUserList = users; + pointsDeVente = points; + isLoading = false; }); } catch (e) { - print(e); + print('Erreur lors du chargement: $e'); + setState(() { + isLoading = false; + }); + _showErrorSnackbar('Erreur lors du chargement des données'); } } - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: CustomAppBar(title: 'Liste des utilisateurs'), - body: ListView.builder( - itemCount: userList.length, - itemBuilder: (context, index) { - Users user = userList[index]; - return Card( - elevation: 3, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - shadowColor: Colors.deepOrange, - borderOnForeground: true, - child: ListTile( - title: Text( - "${user.name} ${user.lastName}", - style: const TextStyle( - fontWeight: FontWeight.bold, - ), + void _filterUsers() { + final query = _searchController.text.toLowerCase(); + setState(() { + searchQuery = query; + filteredUserList = userList.where((user) { + final matchesSearch = query.isEmpty || + user.name.toLowerCase().contains(query) || + user.lastName.toLowerCase().contains(query) || + user.username.toLowerCase().contains(query) || + user.email.toLowerCase().contains(query) || + (user.roleName?.toLowerCase().contains(query) ?? false); + + final matchesPointDeVente = selectedPointDeVenteFilter == null || + user.pointDeVenteId == selectedPointDeVenteFilter; + + return matchesSearch && matchesPointDeVente; + }).toList(); + }); + } + + void _onPointDeVenteFilterChanged(int? pointDeVenteId) { + setState(() { + selectedPointDeVenteFilter = pointDeVenteId; + }); + _filterUsers(); + } + + String _getPointDeVenteName(int? pointDeVenteId) { + if (pointDeVenteId == null) return 'Aucun'; + + try { + final point = pointsDeVente.firstWhere((p) => p['id'] == pointDeVenteId); + return point['nom'] ?? 'Inconnu'; + } catch (e) { + return 'Inconnu'; + } + } + + Future _deleteUser(Users user, int index) async { + // Vérifier si l'utilisateur peut être supprimé + final canDelete = await _checkCanDeleteUser(user); + + if (!canDelete['canDelete']) { + _showCannotDeleteDialog(user, canDelete['reason']); + return; + } + + // Afficher la confirmation de suppression + final confirmed = await _showDeleteConfirmation(user); + if (!confirmed) return; + + try { + await AppDatabase.instance.deleteUser(user.id!); + + setState(() { + userList.removeWhere((u) => u.id == user.id); + filteredUserList.removeWhere((u) => u.id == user.id); + }); + + _showSuccessSnackbar('Utilisateur supprimé avec succès'); + } catch (e) { + print('Erreur lors de la suppression: $e'); + _showErrorSnackbar('Erreur lors de la suppression de l\'utilisateur'); + } + } + + Future> _checkCanDeleteUser(Users user) async { + // Ici vous pouvez ajouter des vérifications métier + // Par exemple, vérifier si l'utilisateur a des commandes en cours, etc. + + // Pour l'instant, on autorise la suppression sauf pour le Super Admin + if (user.roleName?.toLowerCase() == 'super admin') { + return { + 'canDelete': false, + 'reason': 'Le Super Admin ne peut pas être supprimé pour des raisons de sécurité.' + }; + } + + // Vérifier s'il y a des commandes associées à cet utilisateur + try { + final db = AppDatabase.instance; + final commandes = await db.database.then((connection) => + connection.query('SELECT COUNT(*) as count FROM commandes WHERE commandeurId = ? OR validateurId = ?', + [user.id, user.id]) + ); + + final commandeCount = commandes.first['count'] as int; + if (commandeCount > 0) { + return { + 'canDelete': false, + 'reason': 'Cet utilisateur a $commandeCount commande(s) associée(s). Impossible de le supprimer.' + }; + } + } catch (e) { + print('Erreur lors de la vérification des contraintes: $e'); + } + + return {'canDelete': true, 'reason': ''}; + } + + Future _showDeleteConfirmation(Users user) async { + return await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: const [ + Icon(Icons.warning, color: Colors.orange), + SizedBox(width: 8), + Text("Confirmer la suppression"), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("Êtes-vous sûr de vouloir supprimer cet utilisateur ?"), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), ), - subtitle: Column( + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const SizedBox(height: 4), + Text("Nom: ${user.name} ${user.lastName}", + style: const TextStyle(fontWeight: FontWeight.w500)), Text("Username: ${user.username}"), - const SizedBox(height: 4), - Text("Privilège: ${user.role}"), + Text("Email: ${user.email}"), + Text("Rôle: ${user.roleName ?? 'N/A'}"), + Text("Point de vente: ${_getPointDeVenteName(user.pointDeVenteId)}"), ], ), - trailing: Row( - mainAxisSize: MainAxisSize.min, + ), + const SizedBox(height: 12), + const Text( + "Cette action est irréversible.", + style: TextStyle(color: Colors.red, fontStyle: FontStyle.italic), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text("Annuler"), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + ), + child: const Text("Supprimer"), + ), + ], + ), + ) ?? false; + } + + void _showCannotDeleteDialog(Users user, String reason) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Row( + children: const [ + Icon(Icons.block, color: Colors.red), + SizedBox(width: 8), + Text("Suppression impossible"), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "L'utilisateur ${user.name} ${user.lastName} ne peut pas être supprimé.", + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.red.shade200), + ), + child: Row( + children: [ + const Icon(Icons.info, color: Colors.red, size: 20), + const SizedBox(width: 8), + Expanded(child: Text(reason)), + ], + ), + ), + ], + ), + actions: [ + ElevatedButton( + onPressed: () => Navigator.of(context).pop(), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: const Text("Compris"), + ), + ], + ), + ); + } + + void _showUserDetails(Users user) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text("Détails de ${user.name} ${user.lastName}"), + content: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildDetailRow("ID", "${user.id}"), + _buildDetailRow("Prénom", user.name), + _buildDetailRow("Nom", user.lastName), + _buildDetailRow("Username", user.username), + _buildDetailRow("Email", user.email), + _buildDetailRow("Rôle", user.roleName ?? 'N/A'), + _buildDetailRow("Point de vente", _getPointDeVenteName(user.pointDeVenteId)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text("Fermer"), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + Get.to(() => EditUserPage(user: user))?.then((_) => _loadData()); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + ), + child: const Text("Modifier"), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 100, + child: Text( + "$label:", + style: const TextStyle(fontWeight: FontWeight.w500), + ), + ), + Expanded(child: Text(value)), + ], + ), + ); + } + + void _showSuccessSnackbar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle, color: Colors.white), + const SizedBox(width: 8), + Text(message), + ], + ), + backgroundColor: Colors.green, + duration: const Duration(seconds: 3), + ), + ); + } + + void _showErrorSnackbar(String message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error, color: Colors.white), + const SizedBox(width: 8), + Text(message), + ], + ), + backgroundColor: Colors.red, + duration: const Duration(seconds: 4), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: CustomAppBar(title: 'Liste des utilisateurs'), + body: isLoading + ? const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - IconButton( - icon: const Icon(Icons.delete), - color: Colors.red, - onPressed: () { - // Action de suppression - // Vous pouvez appeler une méthode de suppression appropriée ici - // confirmation de suppression - showDialog( - context: context, - builder: (context) { - return AlertDialog( - title: const Text("Supprimer"), - content: const Text( - "Êtes-vous sûr de vouloir supprimer cet utilisateur?"), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("Annuler"), + CircularProgressIndicator(), + SizedBox(height: 16), + Text('Chargement des utilisateurs...'), + ], + ), + ) + : Column( + children: [ + // Barre de recherche et filtres + Container( + padding: const EdgeInsets.all(16), + color: Colors.grey.shade50, + child: Column( + children: [ + // Barre de recherche + TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Rechercher par nom, username, email...', + prefixIcon: const Icon(Icons.search), + suffixIcon: searchQuery.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + filled: true, + fillColor: Colors.white, + ), + ), + + const SizedBox(height: 12), + + // Filtre par point de vente + Row( + children: [ + const Icon(Icons.filter_list, color: Colors.grey), + const SizedBox(width: 8), + const Text('Point de vente:'), + const SizedBox(width: 12), + Expanded( + child: DropdownButtonFormField( + value: selectedPointDeVenteFilter, + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(6), + ), + filled: true, + fillColor: Colors.white, ), - TextButton( - onPressed: () async { - await AppDatabase.instance - .deleteUser(user.id!); - Navigator.of(context).pop(); - setState(() { - userList.removeAt(index); - }); - }, - child: const Text("Supprimer"), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Tous'), + ), + ...pointsDeVente.map((point) => DropdownMenuItem( + value: point['id'] as int, + child: Text(point['nom'] ?? 'N/A'), + )), + ], + onChanged: _onPointDeVenteFilterChanged, + ), + ), + ], + ), + ], + ), + ), + + // Statistiques + Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: Colors.blue.shade50, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total: ${userList.length} utilisateur(s)', + style: const TextStyle(fontWeight: FontWeight.w500), + ), + if (filteredUserList.length != userList.length) + Text( + 'Affichés: ${filteredUserList.length}', + style: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + + // Liste des utilisateurs + Expanded( + child: filteredUserList.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + searchQuery.isNotEmpty ? Icons.search_off : Icons.people_outline, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + searchQuery.isNotEmpty + ? 'Aucun utilisateur trouvé' + : 'Aucun utilisateur dans la base de données', + style: TextStyle( + fontSize: 16, + color: Colors.grey.shade600, + ), ), ], - ); - }, - ); - }, + ), + ) + : RefreshIndicator( + onRefresh: _loadData, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filteredUserList.length, + itemBuilder: (context, index) { + Users user = filteredUserList[index]; + return _buildUserCard(user, index); + }, + ), + ), + ), + ], + ), + ); + } + + Widget _buildUserCard(Users user, int index) { + final pointDeVenteName = _getPointDeVenteName(user.pointDeVenteId); + final isSuperAdmin = user.roleName?.toLowerCase() == 'super admin'; + + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: isSuperAdmin + ? BorderSide(color: Colors.orange.shade300, width: 1) + : BorderSide.none, + ), + child: InkWell( + onTap: () => _showUserDetails(user), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // En-tête avec nom et badge + Row( + children: [ + CircleAvatar( + backgroundColor: isSuperAdmin ? Colors.orange.shade100 : Colors.blue.shade100, + child: Icon( + isSuperAdmin ? Icons.admin_panel_settings : Icons.person, + color: isSuperAdmin ? Colors.orange.shade700 : Colors.blue.shade700, + ), ), - IconButton( - icon: const Icon(Icons.edit), - color: Colors.blue, - onPressed: () { - // Action de modification - // Vous pouvez naviguer vers la page de modification avec les détails de l'utilisateur - // en utilisant Navigator.push ou showDialog, selon votre besoin - Get.to(() => EditUserPage(user: user)); - }, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + "${user.name} ${user.lastName}", + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 16, + ), + ), + Text( + "@${user.username}", + style: TextStyle( + color: Colors.grey.shade600, + fontSize: 14, + ), + ), + ], + ), ), + if (isSuperAdmin) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.shade300), + ), + child: Text( + 'ADMIN', + style: TextStyle( + color: Colors.orange.shade800, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + ), ], ), - ), - ); - }, + + const SizedBox(height: 12), + + // Informations détaillées + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildInfoChip(Icons.email, user.email), + const SizedBox(height: 4), + _buildInfoChip(Icons.badge, user.roleName ?? 'N/A'), + const SizedBox(height: 4), + _buildInfoChip(Icons.store, pointDeVenteName), + ], + ), + ), + + // Boutons d'actions + Column( + children: [ + IconButton( + icon: const Icon(Icons.visibility, size: 20), + color: Colors.blue.shade600, + onPressed: () => _showUserDetails(user), + tooltip: 'Voir les détails', + ), + IconButton( + icon: const Icon(Icons.edit, size: 20), + color: Colors.green.shade600, + onPressed: () { + Get.to(() => EditUserPage(user: user))?.then((_) => _loadData()); + }, + tooltip: 'Modifier', + ), + IconButton( + icon: Icon( + isSuperAdmin ? Icons.lock : Icons.delete, + size: 20, + ), + color: isSuperAdmin ? Colors.grey : Colors.red.shade600, + onPressed: isSuperAdmin + ? null + : () => _deleteUser(user, index), + tooltip: isSuperAdmin ? 'Protection Super Admin' : 'Supprimer', + ), + ], + ), + ], + ), + ], + ), + ), ), ); } -} + + Widget _buildInfoChip(IconData icon, String text) { + return Row( + children: [ + Icon(icon, size: 14, color: Colors.grey.shade600), + const SizedBox(width: 6), + Expanded( + child: Text( + text, + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade700, + ), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ); + } + } \ No newline at end of file diff --git a/lib/Views/newCommand.dart b/lib/Views/newCommand.dart index e80f3c6..ff1f5b8 100644 --- a/lib/Views/newCommand.dart +++ b/lib/Views/newCommand.dart @@ -9,6 +9,7 @@ import 'package:youmazgestion/Models/client.dart'; import 'package:youmazgestion/Models/users.dart'; import 'package:youmazgestion/Models/produit.dart'; import 'package:youmazgestion/Services/stock_managementDatabase.dart'; +import 'package:youmazgestion/controller/userController.dart'; class NouvelleCommandePage extends StatefulWidget { const NouvelleCommandePage({super.key}); @@ -33,7 +34,9 @@ class _NouvelleCommandePageState extends State { final TextEditingController _searchNameController = TextEditingController(); final TextEditingController _searchImeiController = TextEditingController(); final TextEditingController _searchReferenceController = TextEditingController(); - + List> _pointsDeVente = []; + String? _selectedPointDeVente; + final UserController _userController = Get.find(); // Panier final List _products = []; final List _filteredProducts = []; @@ -57,32 +60,83 @@ class _NouvelleCommandePageState extends State { final GlobalKey _qrKey = GlobalKey(debugLabel: 'QR'); @override - void initState() { - super.initState(); - _loadProducts(); - _loadCommercialUsers(); - - // Listeners pour les filtres - _searchNameController.addListener(_filterProducts); - _searchImeiController.addListener(_filterProducts); - _searchReferenceController.addListener(_filterProducts); - - // Listeners pour l'autocomplétion client - _nomController.addListener(() { - if (_nomController.text.length >= 3) { - _showClientSuggestions(_nomController.text, isNom: true); - } else { - _hideNomSuggestions(); +void initState() { + super.initState(); + _loadProducts(); + _loadPointsDeVenteWithDefault(); // Charger les points de vente + _searchNameController.addListener(_filterProducts); + _searchImeiController.addListener(_filterProducts); + _searchReferenceController.addListener(_filterProducts); +} +Future _loadPointsDeVenteWithDefault() async { + try { + final points = await _appDatabase.getPointsDeVente(); + setState(() { + _pointsDeVente = points; + + if (points.isNotEmpty) { + if (_userController.pointDeVenteId > 0) { + final userPointDeVente = points.firstWhere( + (point) => point['id'] == _userController.pointDeVenteId, + orElse: () => {}, + ); + + if (userPointDeVente.isNotEmpty) { + _selectedPointDeVente = userPointDeVente['nom'] as String; + } else { + _selectedPointDeVente = points[0]['nom'] as String; + } + } else { + _selectedPointDeVente = points[0]['nom'] as String; + } } }); + + _filterProducts(); // Appliquer le filtre dès le chargement + } catch (e) { + Get.snackbar('Erreur', 'Impossible de charger les points de vente: $e'); + print('❌ Erreur chargement points de vente: $e'); + } +} +bool _isUserSuperAdmin() { + return _userController.role == 'Super Admin'; +} +bool _isProduitCommandable(Product product) { + if (_isUserSuperAdmin()) { + return true; // Les superadmins peuvent tout commander + } + + // Les autres utilisateurs ne peuvent commander que les produits de leur PV + return product.pointDeVenteId == _userController.pointDeVenteId; +} + + + // 🎯 MÉTHODE UTILITAIRE: Obtenir l'ID du point de vente sélectionné + int? _getSelectedPointDeVenteId() { + if (_selectedPointDeVente == null) return null; - _telephoneController.addListener(() { - if (_telephoneController.text.length >= 3) { - _showClientSuggestions(_telephoneController.text, isNom: false); - } else { - _hideTelephoneSuggestions(); - } - }); + final pointDeVente = _pointsDeVente.firstWhere( + (point) => point['nom'] == _selectedPointDeVente, + orElse: () => {}, + ); + + return pointDeVente.isNotEmpty ? pointDeVente['id'] as int : null; + } + + // 2. Ajoutez cette méthode pour charger les points de vente + // 2. Ajoutez cette méthode pour charger les points de vente + Future _loadPointsDeVente() async { + try { + final points = await _appDatabase.getPointsDeVente(); + setState(() { + _pointsDeVente = points; + if (points.isNotEmpty) { + _selectedPointDeVente = points.first['nom'] as String; + } + }); + } catch (e) { + Get.snackbar('Erreur', 'Impossible de charger les points de vente: $e'); + } } // ==Gestion des remise @@ -154,37 +208,68 @@ class _NouvelleCommandePageState extends State { } // Ajout des produits au pannier // 4. Modifier la méthode pour ajouter des produits au panier - void _ajouterAuPanier(Product product, int quantite) { - // Vérifier le stock disponible - if (product.stock != null && quantite > product.stock!) { - Get.snackbar( - 'Stock insuffisant', - 'Quantité demandée non disponible', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, - ); - return; - } + // 🎯 MODIFIÉ: Validation avant ajout au panier +// 🎯 MODIFIÉ: Validation avant ajout au panier (inchangée) +void _ajouterAuPanier(Product product, int quantite) { + // 🔒 VÉRIFICATION SÉCURITÉ: Non-superadmin ne peut commander que ses produits + if (!_isProduitCommandable(product)) { + Get.snackbar( + 'Produit non commandable', + 'Ce produit appartient à un autre point de vente. Seuls les produits de votre point de vente "${_userController.pointDeVenteDesignation}" sont commandables.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange.shade600, + colorText: Colors.white, + icon: const Icon(Icons.info, color: Colors.white), + duration: const Duration(seconds: 5), + ); + return; + } - setState(() { - final detail = DetailCommande.sansRemise( - commandeId: 0, // Sera défini lors de la création - produitId: product.id!, - quantite: quantite, - prixUnitaire: product.price, - produitNom: product.name, - produitReference: product.reference, - ); - _panierDetails[product.id!] = detail; - }); + // Vérifier le stock disponible + if (product.stock != null && quantite > product.stock!) { + Get.snackbar( + 'Stock insuffisant', + 'Quantité demandée non disponible', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; } - // Modification de la méthode _modifierQuantite pour gérer les cadeaux + setState(() { + final detail = DetailCommande.sansRemise( + commandeId: 0, + produitId: product.id!, + quantite: quantite, + prixUnitaire: product.price, + produitNom: product.name, + produitReference: product.reference, + ); + _panierDetails[product.id!] = detail; + }); +} + + + // 🎯 MODIFIÉ: Validation lors de la modification de quantité void _modifierQuantite(int productId, int nouvelleQuantite) { final detailExistant = _panierDetails[productId]; if (detailExistant == null) return; + final product = _products.firstWhere((p) => p.id == productId); + + // 🔒 VÉRIFICATION SÉCURITÉ supplémentaire + if (!_isProduitCommandable(product)) { + Get.snackbar( + 'Modification impossible', + 'Vous ne pouvez modifier que les produits de votre point de vente', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange.shade600, + colorText: Colors.white, + ); + return; + } + if (nouvelleQuantite <= 0) { setState(() { _panierDetails.remove(productId); @@ -192,7 +277,7 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { return; } - final product = _products.firstWhere((p) => p.id == productId); + // ... reste du code existant pour la modification if (product.stock != null && nouvelleQuantite > product.stock!) { Get.snackbar( 'Stock insuffisant', @@ -216,7 +301,7 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { quantite: nouvelleQuantite, prixUnitaire: detailExistant.prixUnitaire, sousTotal: nouveauSousTotal, - prixFinal: 0.0, // Prix final à 0 pour un cadeau + prixFinal: 0.0, estCadeau: true, produitNom: detailExistant.produitNom, produitReference: detailExistant.produitReference, @@ -834,15 +919,19 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { _hideTelephoneSuggestions(); } - Future _loadProducts() async { - final products = await _appDatabase.getProducts(); - setState(() { - _products.clear(); - _products.addAll(products); - _filteredProducts.clear(); - _filteredProducts.addAll(products); - }); - } +// 🎯 MODIFIÉ: Chargement de TOUS les produits (visibilité totale) +Future _loadProducts() async { + final products = await _appDatabase.getProducts(); + setState(() { + _products.clear(); + // ✅ TOUS les utilisateurs voient TOUS les produits + _products.addAll(products); + print("✅ Produits chargés: ${products.length} (tous visibles)"); + + _filteredProducts.clear(); + _filteredProducts.addAll(_products); + }); +} Future _loadCommercialUsers() async { final commercialUsers = await _appDatabase.getCommercialUsers(); @@ -854,34 +943,47 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { }); } - void _filterProducts() { - final nameQuery = _searchNameController.text.toLowerCase(); - final imeiQuery = _searchImeiController.text.toLowerCase(); - final referenceQuery = _searchReferenceController.text.toLowerCase(); - setState(() { - _filteredProducts.clear(); - - for (var product in _products) { - bool matchesName = nameQuery.isEmpty || - product.name.toLowerCase().contains(nameQuery); - - bool matchesImei = imeiQuery.isEmpty || - (product.imei?.toLowerCase().contains(imeiQuery) ?? false); - - bool matchesReference = referenceQuery.isEmpty || - (product.reference?.toLowerCase().contains(referenceQuery) ?? false); - - bool matchesStock = !_showOnlyInStock || - (product.stock != null && product.stock! > 0); + - if (matchesName && matchesImei && matchesReference && matchesStock) { - _filteredProducts.add(product); - } + // 🎯 MODIFIÉ: Filtrage avec visibilité totale mais indication des restrictions +void _filterProducts() { + final nameQuery = _searchNameController.text.toLowerCase(); + final imeiQuery = _searchImeiController.text.toLowerCase(); + final referenceQuery = _searchReferenceController.text.toLowerCase(); + final selectedPointDeVenteId = _getSelectedPointDeVenteId(); + + setState(() { + _filteredProducts.clear(); + + for (var product in _products) { + bool matchesName = nameQuery.isEmpty || + product.name.toLowerCase().contains(nameQuery); + bool matchesImei = imeiQuery.isEmpty || + (product.imei?.toLowerCase().contains(imeiQuery) ?? false); + bool matchesReference = referenceQuery.isEmpty || + (product.reference?.toLowerCase().contains(referenceQuery) ?? false); + bool matchesStock = + !_showOnlyInStock || (product.stock != null && product.stock! > 0); + + // Appliquer le filtre par point de vente uniquement si un point est sélectionné + bool matchesPointDeVente = true; + if (selectedPointDeVenteId != null) { + matchesPointDeVente = product.pointDeVenteId == selectedPointDeVenteId; } - }); - } + if (matchesName && + matchesImei && + matchesReference && + matchesStock && + matchesPointDeVente) { + _filteredProducts.add(product); + } + } + }); + + print("🔍 Filtrage: ${_filteredProducts.length} produits visibles"); +} void _toggleStockFilter() { setState(() { _showOnlyInStock = !_showOnlyInStock; @@ -889,17 +991,37 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { _filterProducts(); } + // 🎯 MÉTHODE UTILITAIRE: Reset des filtres avec point de vente utilisateur void _clearFilters() { - setState(() { - _searchNameController.clear(); - _searchImeiController.clear(); - _searchReferenceController.clear(); - _showOnlyInStock = false; - }); - _filterProducts(); - } + setState(() { + _searchNameController.clear(); + _searchImeiController.clear(); + _searchReferenceController.clear(); + _showOnlyInStock = false; + + // Réinitialiser au point de vente de l'utilisateur connecté + if (_userController.pointDeVenteId > 0) { + final userPointDeVente = _pointsDeVente.firstWhere( + (point) => point['id'] == _userController.pointDeVenteId, + orElse: () => {}, + ); + if (userPointDeVente.isNotEmpty) { + _selectedPointDeVente = userPointDeVente['nom'] as String; + } else { + _selectedPointDeVente = null; // Fallback si le point de vente n'existe plus + } + } else { + _selectedPointDeVente = null; + } + }); + + _filterProducts(); + print("🔄 Filtres réinitialisés - Point de vente: $_selectedPointDeVente"); +} + + - // Section des filtres adaptée pour mobile + // 11. Modifiez la section des filtres pour inclure le bouton de réinitialisation Widget _buildFilterSection() { final isMobile = MediaQuery.of(context).size.width < 600; @@ -1049,7 +1171,7 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { const SizedBox(height: 8), - // Compteur de résultats + // Compteur de résultats avec indicateurs de filtres actifs Container( padding: const EdgeInsets.symmetric( horizontal: 12, @@ -1059,13 +1181,40 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { color: Colors.blue.shade50, borderRadius: BorderRadius.circular(20), ), - child: Text( - '${_filteredProducts.length} produit(s)', - style: TextStyle( - color: Colors.blue.shade700, - fontWeight: FontWeight.w600, - fontSize: isMobile ? 12 : 14, - ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${_filteredProducts.length} produit(s)', + style: TextStyle( + color: Colors.blue.shade700, + fontWeight: FontWeight.w600, + fontSize: isMobile ? 12 : 14, + ), + ), + // Indicateurs de filtres actifs + if (_selectedPointDeVente != null || _showOnlyInStock || + _searchNameController.text.isNotEmpty || + _searchImeiController.text.isNotEmpty || + _searchReferenceController.text.isNotEmpty) ...[ + const SizedBox(height: 4), + Wrap( + spacing: 4, + children: [ + if (_selectedPointDeVente != null) + _buildFilterChip('PV: $_selectedPointDeVente'), + if (_showOnlyInStock) + _buildFilterChip('En stock'), + if (_searchNameController.text.isNotEmpty) + _buildFilterChip('Nom: ${_searchNameController.text}'), + if (_searchImeiController.text.isNotEmpty) + _buildFilterChip('IMEI: ${_searchImeiController.text}'), + if (_searchReferenceController.text.isNotEmpty) + _buildFilterChip('Réf: ${_searchReferenceController.text}'), + ], + ), + ], + ], ), ), ], @@ -1073,26 +1222,89 @@ void _modifierQuantite(int productId, int nouvelleQuantite) { ), ); } +Widget _buildFilterChip(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.shade300), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + color: Colors.orange.shade700, + fontWeight: FontWeight.w500, + ), + ), + ); + } Widget _buildFloatingCartButton() { - final isMobile = MediaQuery.of(context).size.width < 600; - final cartItemCount = _quantites.values.where((q) => q > 0).length; - + final isMobile = MediaQuery.of(context).size.width < 600; + final cartItemCount = _panierDetails.values.where((d) => d.quantite > 0).length; + + if (isMobile) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FloatingActionButton( + heroTag: "scan_btn", + onPressed: _isScanning ? null : _startAutomaticScanning, + backgroundColor: _isScanning ? Colors.grey : Colors.green.shade700, + foregroundColor: Colors.white, + mini: true, + child: _isScanning + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Icon(Icons.qr_code_scanner), + ), + const SizedBox(width: 8), + FloatingActionButton.extended( + onPressed: _showCartBottomSheet, + icon: const Icon(Icons.shopping_cart), + label: Text('$cartItemCount'), + backgroundColor: Colors.blue.shade800, + foregroundColor: Colors.white, + ), + ], + ); + } else { return FloatingActionButton.extended( - onPressed: () { - _showCartBottomSheet(); - }, + onPressed: _showCartBottomSheet, icon: const Icon(Icons.shopping_cart), - label: Text( - isMobile ? 'Panier ($cartItemCount)' : 'Panier ($cartItemCount)', - style: TextStyle(fontSize: isMobile ? 12 : 14), - ), + label: Text('Panier ($cartItemCount)'), backgroundColor: Colors.blue.shade800, foregroundColor: Colors.white, ); } - +} +// Nouvelle méthode pour afficher les filtres sur mobile +void _showMobileFilters(BuildContext context) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => SingleChildScrollView( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: Column( + children: [ + _buildPointDeVenteFilter(), + _buildFilterSection(), + ], + ), + ), + ); +} void _showClientFormDialog() { final isMobile = MediaQuery.of(context).size.width < 600; @@ -1609,112 +1821,31 @@ Widget _buildSuggestionOverlay({ validator: (value) => value == null ? 'Veuillez sélectionner un commercial' : null, ); } +Widget _buildUserPointDeVenteInfo() { + if (_userController.pointDeVenteId <= 0) { + return const SizedBox.shrink(); + } - Widget _buildProductList() { - final isMobile = MediaQuery.of(context).size.width < 600; - - return _filteredProducts.isEmpty - ? _buildEmptyState() - : ListView.builder( - padding: const EdgeInsets.all(16.0), - itemCount: _filteredProducts.length, - itemBuilder: (context, index) { - final product = _filteredProducts[index]; - final quantity = _quantites[product.id] ?? 0; - - return _buildProductListItem(product, quantity, isMobile); - }, - ); - } - - Widget _buildEmptyState() { - return Center( - child: Padding( - padding: const EdgeInsets.all(32.0), - child: Column( - children: [ - Icon( - Icons.search_off, - size: 64, - color: Colors.grey.shade400, - ), - const SizedBox(height: 16), - Text( - 'Aucun produit trouvé', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 8), - Text( - 'Modifiez vos critères de recherche', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade500, - ), - ), - ], - ), - ), - ); - } - - // Modification de la méthode _buildProductListItem pour inclure le bouton cadeau -Widget _buildProductListItem(Product product, int quantity, bool isMobile) { - final bool isOutOfStock = product.stock != null && product.stock! <= 0; - final detailPanier = _panierDetails[product.id!]; - final int currentQuantity = detailPanier?.quantite ?? 0; - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - ), - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(8), - border: isOutOfStock - ? Border.all(color: Colors.red.shade200, width: 1.5) - : detailPanier?.estCadeau == true - ? Border.all(color: Colors.green.shade300, width: 2) - : detailPanier?.aRemise == true - ? Border.all(color: Colors.orange.shade300, width: 2) - : null, + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(12.0), child: Row( children: [ Container( - width: isMobile ? 40 : 50, - height: isMobile ? 40 : 50, + padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: isOutOfStock - ? Colors.red.shade50 - : detailPanier?.estCadeau == true - ? Colors.green.shade50 - : detailPanier?.aRemise == true - ? Colors.orange.shade50 - : Colors.blue.shade50, + color: Colors.blue.shade100, borderRadius: BorderRadius.circular(8), ), child: Icon( - detailPanier?.estCadeau == true - ? Icons.card_giftcard - : detailPanier?.aRemise == true - ? Icons.discount - : Icons.shopping_bag, - size: isMobile ? 20 : 24, - color: isOutOfStock - ? Colors.red - : detailPanier?.estCadeau == true - ? Colors.green.shade700 - : detailPanier?.aRemise == true - ? Colors.orange.shade700 - : Colors.blue, + Icons.store, + color: Colors.blue.shade700, + size: 20, ), ), const SizedBox(width: 12), @@ -1722,272 +1853,1308 @@ Widget _buildProductListItem(Product product, int quantity, bool isMobile) { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Expanded( - child: Text( - product.name, - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: isMobile ? 14 : 16, - color: isOutOfStock ? Colors.red.shade700 : null, - ), - ), - ), - if (detailPanier?.estCadeau == true) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.green.shade100, - borderRadius: BorderRadius.circular(10), - ), - child: Text( - 'CADEAU', - style: TextStyle( - fontSize: 10, - fontWeight: FontWeight.bold, - color: Colors.green.shade700, - ), - ), - ), - ], - ), + const Text( + 'Votre point de vente', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color.fromARGB(255, 9, 56, 95), + ), + ), const SizedBox(height: 4), - Row( + Text( + _userController.pointDeVenteDesignation, + style: TextStyle( + fontSize: 12, + color: Colors.blue.shade700, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade200), + ), + child: Text( + 'ID: ${_userController.pointDeVenteId}', + style: TextStyle( + fontSize: 10, + color: Colors.blue.shade600, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ); + } + + // 6. Ajoutez cette méthode pour filtrer les produits par point de vente + // 🎯 MODIFIÉ: Dropdown avec gestion améliorée +Widget _buildPointDeVenteFilter() { + if (!_isUserSuperAdmin()) { + return const SizedBox.shrink(); // Cacher pour les non-admins + } + + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon(Icons.filter_list, color: Colors.green.shade700), + const SizedBox(width: 8), + const Text('Filtrer par point de vente (Admin)', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _selectedPointDeVente, + decoration: InputDecoration(labelText: 'Point de vente'), + items: [ + const DropdownMenuItem( + value: null, child: Text('Tous les points de vente')), + ..._pointsDeVente.map((point) { + return DropdownMenuItem( + value: point['nom'] as String, + child: Text(point['nom'] as String), + ); + }).toList(), + ], + onChanged: (value) { + setState(() { + _selectedPointDeVente = value; + _filterProducts(); + }); + }, + ), + ], + ), + ), + ); +} + + // 🎯 MODIFIÉ: Interface utilisateur adaptée selon le rôle +// 🎯 NOUVEAU: Header d'information adapté +Widget _buildRoleBasedHeader() { + final commandableCount = _products.where((p) => _isProduitCommandable(p)).length; + final totalCount = _products.length; + + return Card( + elevation: 2, + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: _isUserSuperAdmin() + ? Colors.purple.shade100 + : Colors.blue.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + _isUserSuperAdmin() ? Icons.admin_panel_settings : Icons.visibility, + color: _isUserSuperAdmin() + ? Colors.purple.shade700 + : Colors.blue.shade700, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isUserSuperAdmin() ? 'Mode Administrateur' : 'Mode Consultation étendue', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: _isUserSuperAdmin() + ? Colors.purple.shade700 + : Colors.blue.shade700, + ), + ), + const SizedBox(height: 4), + Text( + _isUserSuperAdmin() + ? 'Tous les produits sont visibles et commandables' + : 'Tous les produits sont visibles • Commandes limitées à votre PV', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: _isUserSuperAdmin() + ? Colors.purple.shade50 + : Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _isUserSuperAdmin() + ? Colors.purple.shade200 + : Colors.blue.shade200 + ), + ), + child: Text( + _userController.role.toUpperCase(), + style: TextStyle( + fontSize: 10, + color: _isUserSuperAdmin() + ? Colors.purple.shade600 + : Colors.blue.shade600, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + + // Statistiques de produits + const SizedBox(height: 12), + Row( + children: [ + // Produits visibles + Expanded( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade200), + ), + child: Row( children: [ - if (detailPanier?.estCadeau == true) ...[ - Text( - 'Gratuit', + Icon(Icons.visibility, size: 16, color: Colors.blue.shade600), + const SizedBox(width: 8), + Expanded( + child: Text( + '$totalCount produit(s) visibles', style: TextStyle( - color: Colors.green.shade700, - fontWeight: FontWeight.bold, - fontSize: isMobile ? 12 : 14, + fontSize: 12, + color: Colors.blue.shade600, + fontWeight: FontWeight.w500, ), ), + ), + ], + ), + ), + ), + + if (!_isUserSuperAdmin()) ...[ + const SizedBox(width: 8), + // Produits commandables + Expanded( + child: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.green.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.green.shade200), + ), + child: Row( + children: [ + Icon(Icons.shopping_cart, size: 16, color: Colors.green.shade600), const SizedBox(width: 8), - Text( - '${product.price.toStringAsFixed(2)} MGA', - style: TextStyle( - color: Colors.grey.shade500, - fontWeight: FontWeight.w600, - fontSize: isMobile ? 11 : 13, - decoration: TextDecoration.lineThrough, - ), - ), - ] else ...[ - Text( - '${product.price.toStringAsFixed(2)} MGA', - style: TextStyle( - color: Colors.green.shade700, - fontWeight: FontWeight.w600, - fontSize: isMobile ? 12 : 14, - decoration: detailPanier?.aRemise == true - ? TextDecoration.lineThrough - : null, - ), - ), - if (detailPanier?.aRemise == true) ...[ - const SizedBox(width: 8), - Text( - '${(detailPanier!.prixFinal / detailPanier.quantite).toStringAsFixed(2)} MGA', + Expanded( + child: Text( + '$commandableCount commandables', style: TextStyle( - color: Colors.orange.shade700, - fontWeight: FontWeight.bold, - fontSize: isMobile ? 12 : 14, + fontSize: 12, + color: Colors.green.shade600, + fontWeight: FontWeight.w500, ), ), - ], + ), ], - ], + ), ), - if (detailPanier?.aRemise == true && !detailPanier!.estCadeau) - Text( - 'Remise: ${detailPanier!.remiseDescription}', - style: TextStyle( - fontSize: isMobile ? 10 : 12, - color: Colors.orange.shade600, - fontWeight: FontWeight.w500, + ), + ], + ], + ), + ], + ), + ), + ); +} + Widget _buildProductList() { + final isMobile = MediaQuery.of(context).size.width < 600; + + return _filteredProducts.isEmpty + ? _buildEmptyState() + : ListView.builder( + padding: const EdgeInsets.all(16.0), + itemCount: _filteredProducts.length, + itemBuilder: (context, index) { + final product = _filteredProducts[index]; + final quantity = _quantites[product.id] ?? 0; + + return _buildProductListItem(product, quantity, isMobile); + }, + ); + } + + Widget _buildEmptyState() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32.0), + child: Column( + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'Aucun produit trouvé', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), + ), + const SizedBox(height: 8), + Text( + 'Modifiez vos critères de recherche', + style: TextStyle( + fontSize: 14, + color: Colors.grey.shade500, + ), + ), + ], + ), + ), + ); + } + + // 🎯 MODIFIÉ: Interface produit avec indication visuelle de la commandabilité +Widget _buildProductListItem(Product product, int quantity, bool isMobile) { + final bool isOutOfStock = product.stock != null && product.stock! <= 0; + final detailPanier = _panierDetails[product.id!]; + final int currentQuantity = detailPanier?.quantite ?? 0; + final isCurrentUserPointDeVente = product.pointDeVenteId == _userController.pointDeVenteId; + final isProduitCommandable = _isProduitCommandable(product); + + return FutureBuilder( + future: _appDatabase.getPointDeVenteNomById(product.pointDeVenteId ?? 0), + builder: (context, snapshot) { + String pointDeVenteText = 'Chargement...'; + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasError) { + pointDeVenteText = 'Erreur de chargement'; + } else { + pointDeVenteText = snapshot.data ?? 'Non spécifié'; + } + } + + return Card( + margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + elevation: 2, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: isCurrentUserPointDeVente + ? BorderSide(color: Colors.orange.shade300, width: 2) + : !isProduitCommandable + ? BorderSide(color: Colors.grey.shade300, width: 1.5) + : BorderSide.none, + ), + child: Opacity( + opacity: isProduitCommandable ? 1.0 : 0.7, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + border: isOutOfStock + ? Border.all(color: Colors.red.shade200, width: 1.5) + : detailPanier?.estCadeau == true + ? Border.all(color: Colors.green.shade300, width: 2) + : detailPanier?.aRemise == true + ? Border.all(color: Colors.orange.shade300, width: 2) + : isCurrentUserPointDeVente + ? Border.all(color: Colors.orange.shade300, width: 2) + : !isProduitCommandable + ? Border.all(color: Colors.grey.shade200, width: 1) + : null, + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + children: [ + Row( + children: [ + Container( + width: isMobile ? 40 : 50, + height: isMobile ? 40 : 50, + decoration: BoxDecoration( + color: !isProduitCommandable + ? Colors.grey.shade100 + : isOutOfStock + ? Colors.red.shade50 + : detailPanier?.estCadeau == true + ? Colors.green.shade50 + : detailPanier?.aRemise == true + ? Colors.orange.shade50 + : isCurrentUserPointDeVente + ? Colors.orange.shade50 + : Colors.blue.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + !isProduitCommandable + ? Icons.lock_outline + : detailPanier?.estCadeau == true + ? Icons.card_giftcard + : detailPanier?.aRemise == true + ? Icons.discount + : isCurrentUserPointDeVente + ? Icons.store + : Icons.shopping_bag, + size: isMobile ? 20 : 24, + color: !isProduitCommandable + ? Colors.grey.shade500 + : isOutOfStock + ? Colors.red + : detailPanier?.estCadeau == true + ? Colors.green.shade700 + : detailPanier?.aRemise == true + ? Colors.orange.shade700 + : isCurrentUserPointDeVente + ? Colors.orange.shade700 + : Colors.blue, + ), ), - ), - if (product.stock != null) - Text( - 'Stock: ${product.stock}${isOutOfStock ? ' (Rupture)' : ''}', - style: TextStyle( - fontSize: isMobile ? 10 : 12, - color: isOutOfStock - ? Colors.red.shade600 - : Colors.grey.shade600, - fontWeight: isOutOfStock ? FontWeight.w600 : FontWeight.normal, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + product.name, + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: isMobile ? 14 : 16, + color: !isProduitCommandable + ? Colors.grey.shade600 + : isOutOfStock + ? Colors.red.shade700 + : null, + ), + ), + ), + // Indicateurs de statut + if (!isProduitCommandable && !_isUserSuperAdmin()) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.grey.shade200, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_outline, size: 10, color: Colors.grey.shade600), + const SizedBox(width: 2), + Text( + 'AUTRE PV', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + if (detailPanier?.estCadeau == true) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.green.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'CADEAU', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.green.shade700, + ), + ), + ), + if (isCurrentUserPointDeVente && detailPanier?.estCadeau != true && isProduitCommandable) + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + 'MON PV', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: Colors.orange.shade700, + ), + ), + ), + ], + ), + const SizedBox(height: 4), + + // ===== PRIX AVEC GESTION CADEAUX/REMISES ===== + Row( + children: [ + if (detailPanier?.estCadeau == true) ...[ + Text( + 'Gratuit', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.bold, + fontSize: isMobile ? 12 : 14, + ), + ), + const SizedBox(width: 8), + Text( + '${product.price.toStringAsFixed(2)} MGA', + style: TextStyle( + color: Colors.grey.shade500, + fontWeight: FontWeight.w600, + fontSize: isMobile ? 11 : 13, + decoration: TextDecoration.lineThrough, + ), + ), + ] else ...[ + Text( + '${product.price.toStringAsFixed(2)} MGA', + style: TextStyle( + color: Colors.green.shade700, + fontWeight: FontWeight.w600, + fontSize: isMobile ? 12 : 14, + decoration: detailPanier?.aRemise == true + ? TextDecoration.lineThrough + : null, + ), + ), + if (detailPanier?.aRemise == true) ...[ + const SizedBox(width: 8), + Text( + '${(detailPanier!.prixFinal / detailPanier.quantite).toStringAsFixed(2)} MGA', + style: TextStyle( + color: Colors.orange.shade700, + fontWeight: FontWeight.bold, + fontSize: isMobile ? 12 : 14, + ), + ), + ], + ], + ], + ), + + // Affichage remise + if (detailPanier?.aRemise == true && !detailPanier!.estCadeau) + Text( + 'Remise: ${detailPanier!.remiseDescription}', + style: TextStyle( + fontSize: isMobile ? 10 : 12, + color: Colors.orange.shade600, + fontWeight: FontWeight.w500, + ), + ), + + // Stock + if (product.stock != null) + Text( + 'Stock: ${product.stock}${isOutOfStock ? ' (Rupture)' : ''}', + style: TextStyle( + fontSize: isMobile ? 10 : 12, + color: isOutOfStock + ? Colors.red.shade600 + : Colors.grey.shade600, + fontWeight: isOutOfStock ? FontWeight.w600 : FontWeight.normal, + ), + ), + + // ===== AFFICHAGE IMEI ET RÉFÉRENCE ===== + if (product.imei != null && product.imei!.isNotEmpty) + Text( + 'IMEI: ${product.imei}', + style: TextStyle( + fontSize: isMobile ? 9 : 11, + color: Colors.grey.shade600, + fontFamily: 'monospace', + ), + ), + if (product.reference != null && product.reference!.isNotEmpty) + Text( + 'Réf: ${product.reference}', + style: TextStyle( + fontSize: isMobile ? 9 : 11, + color: Colors.grey.shade600, + ), + ), + + // Point de vente + const SizedBox(height: 4), + Row( + children: [ + Icon( + Icons.store, + size: 12, + color: isCurrentUserPointDeVente + ? Colors.orange.shade700 + : !isProduitCommandable + ? Colors.grey.shade500 + : Colors.grey.shade600 + ), + const SizedBox(width: 4), + Expanded( + child: Text( + 'PV: $pointDeVenteText', + style: TextStyle( + fontSize: isMobile ? 9 : 11, + color: isCurrentUserPointDeVente + ? Colors.orange.shade700 + : !isProduitCommandable + ? Colors.grey.shade500 + : Colors.grey.shade600, + fontWeight: isCurrentUserPointDeVente + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + ), + if (!isProduitCommandable && !_isUserSuperAdmin()) + Icon( + Icons.lock_outline, + size: 12, + color: Colors.grey.shade500, + ), + ], + ), + ], + ), + ), + + // ===== CONTRÔLES QUANTITÉ ET ACTIONS ===== + Column( + children: [ + // Boutons d'actions (seulement si commandable ET dans le panier) + if (isProduitCommandable && currentQuantity > 0) ...[ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + // Bouton cadeau + Container( + margin: const EdgeInsets.only(right: 4), + child: IconButton( + icon: Icon( + detailPanier?.estCadeau == true + ? Icons.card_giftcard + : Icons.card_giftcard_outlined, + size: isMobile ? 16 : 18, + color: detailPanier?.estCadeau == true + ? Colors.green.shade700 + : Colors.grey.shade600, + ), + onPressed: isOutOfStock ? null : () => _basculerStatutCadeau(product.id!), + tooltip: detailPanier?.estCadeau == true + ? 'Retirer le statut cadeau' + : 'Marquer comme cadeau', + style: IconButton.styleFrom( + backgroundColor: detailPanier?.estCadeau == true + ? Colors.green.shade100 + : Colors.grey.shade100, + minimumSize: Size(isMobile ? 32 : 36, isMobile ? 32 : 36), + ), + ), + ), + // Bouton remise (seulement pour les articles non-cadeaux) + if (!detailPanier!.estCadeau) + Container( + margin: const EdgeInsets.only(right: 4), + child: IconButton( + icon: Icon( + detailPanier.aRemise + ? Icons.discount + : Icons.local_offer, + size: isMobile ? 16 : 18, + color: detailPanier.aRemise + ? Colors.orange.shade700 + : Colors.grey.shade600, + ), + onPressed: isOutOfStock ? null : () => _showRemiseDialog(product), + tooltip: detailPanier.aRemise + ? 'Modifier la remise' + : 'Ajouter une remise', + style: IconButton.styleFrom( + backgroundColor: detailPanier.aRemise + ? Colors.orange.shade100 + : Colors.grey.shade100, + minimumSize: Size(isMobile ? 32 : 36, isMobile ? 32 : 36), + ), + ), + ), + // Bouton pour ajouter un cadeau à un autre produit + Container( + margin: const EdgeInsets.only(left: 4), + child: IconButton( + icon: Icon( + Icons.add_circle_outline, + size: isMobile ? 16 : 18, + color: Colors.green.shade600, + ), + onPressed: isOutOfStock ? null : () => _showCadeauDialog(product), + tooltip: 'Ajouter un cadeau', + style: IconButton.styleFrom( + backgroundColor: Colors.green.shade50, + minimumSize: Size(isMobile ? 32 : 36, isMobile ? 32 : 36), + ), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + + // Contrôles de quantité (seulement si commandable) + if (isProduitCommandable) + Container( + decoration: BoxDecoration( + color: isOutOfStock + ? Colors.grey.shade100 + : detailPanier?.estCadeau == true + ? Colors.green.shade50 + : isCurrentUserPointDeVente + ? Colors.orange.shade50 + : Colors.blue.shade50, + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon(Icons.remove, size: isMobile ? 16 : 18), + onPressed: isOutOfStock ? null : () { + if (currentQuantity > 0) { + _modifierQuantite(product.id!, currentQuantity - 1); + } + }, + ), + Text( + currentQuantity.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: isMobile ? 12 : 14, + ), + ), + IconButton( + icon: Icon(Icons.add, size: isMobile ? 16 : 18), + onPressed: isOutOfStock ? null : () { + if (product.stock == null || currentQuantity < product.stock!) { + if (currentQuantity == 0) { + _ajouterAuPanier(product, 1); + } else { + _modifierQuantite(product.id!, currentQuantity + 1); + } + } else { + Get.snackbar( + 'Stock insuffisant', + 'Quantité demandée non disponible', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + } + }, + ), + ], + ), + ) + else + // Message informatif pour produits non-commandables + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: Colors.grey.shade300), + ), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.info_outline, size: 14, color: Colors.grey.shade600), + const SizedBox(width: 4), + Text( + 'Consultation', + style: TextStyle( + fontSize: 11, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + const SizedBox(height: 4), + ElevatedButton.icon( + icon: const Icon(Icons.swap_horiz, size: 14), + label: const Text('Demander transfert'), + style: ElevatedButton.styleFrom( + backgroundColor: (product.stock != null && product.stock! >= 1) + ? Colors.blue.shade700 + : Colors.grey.shade400, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + ), + onPressed: (product.stock != null && product.stock! >= 1) + ? () => _showDemandeTransfertDialog(product) + : () { + Get.snackbar( + 'Stock insuffisant', + 'Impossible de demander un transfert : produit en rupture de stock', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.orange.shade600, + colorText: Colors.white, + ); + }, +), + ], + ), + ), + ], ), + ], + ), + ], + ), + ), + ), + ), + ); + }, + ); +} + +// 🎨 INTERFACE AMÉLIORÉE: Dialog moderne pour demande de transfert +Future _showDemandeTransfertDialog(Product product) async { + final quantiteController = TextEditingController(text: '1'); + final notesController = TextEditingController(); + final _formKey = GlobalKey(); + + // Récupérer les infos du point de vente source + final pointDeVenteSource = await _appDatabase.getPointDeVenteNomById(product.pointDeVenteId ?? 0); + final pointDeVenteDestination = await _appDatabase.getPointDeVenteNomById(_userController.pointDeVenteId); + + await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + contentPadding: EdgeInsets.zero, + content: Container( + width: 400, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // En-tête avec design moderne + Container( + width: double.infinity, + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue.shade600, Colors.blue.shade700], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(16), + topRight: Radius.circular(16), + ), + ), + child: Column( + children: [ + Icon( + Icons.swap_horizontal_circle, + size: 48, + color: Colors.white, ), - // Affichage IMEI et Référence - if (product.imei != null && product.imei!.isNotEmpty) + const SizedBox(height: 8), Text( - 'IMEI: ${product.imei}', + 'Demande de transfert', style: TextStyle( - fontSize: isMobile ? 9 : 11, - color: Colors.grey.shade600, - fontFamily: 'monospace', + fontSize: 20, + fontWeight: FontWeight.bold, + color: Colors.white, ), ), - if (product.reference != null && product.reference!.isNotEmpty) Text( - 'Réf: ${product.reference}', + 'Transférer un produit entre points de vente', style: TextStyle( - fontSize: isMobile ? 9 : 11, - color: Colors.grey.shade600, + fontSize: 14, + color: Colors.white.withOpacity(0.9), ), ), - ], + ], + ), ), - ), - // Actions (quantité, remise et cadeau) - Column( - children: [ - // Boutons d'actions (seulement si le produit est dans le panier) - if (currentQuantity > 0) ...[ - Row( - mainAxisSize: MainAxisSize.min, + + // Contenu principal + Padding( + padding: const EdgeInsets.all(20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Bouton cadeau + // Informations du produit Container( - margin: const EdgeInsets.only(right: 4), - child: IconButton( - icon: Icon( - detailPanier?.estCadeau == true - ? Icons.card_giftcard - : Icons.card_giftcard_outlined, - size: isMobile ? 16 : 18, - color: detailPanier?.estCadeau == true - ? Colors.green.shade700 - : Colors.grey.shade600, - ), - onPressed: isOutOfStock ? null : () => _basculerStatutCadeau(product.id!), - tooltip: detailPanier?.estCadeau == true - ? 'Retirer le statut cadeau' - : 'Marquer comme cadeau', - style: IconButton.styleFrom( - backgroundColor: detailPanier?.estCadeau == true - ? Colors.green.shade100 - : Colors.grey.shade100, - minimumSize: Size(isMobile ? 32 : 36, isMobile ? 32 : 36), - ), + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), ), - ), - // Bouton remise (seulement pour les articles non-cadeaux) - if (!detailPanier!.estCadeau) - Container( - margin: const EdgeInsets.only(right: 4), - child: IconButton( - icon: Icon( - detailPanier.aRemise - ? Icons.discount - : Icons.local_offer, - size: isMobile ? 16 : 18, - color: detailPanier.aRemise - ? Colors.orange.shade700 - : Colors.grey.shade600, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + Icons.inventory_2, + color: Colors.blue.shade700, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Produit à transférer', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + ), + Text( + product.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ), + ], ), - onPressed: isOutOfStock ? null : () => _showRemiseDialog(product), - tooltip: detailPanier.aRemise - ? 'Modifier la remise' - : 'Ajouter une remise', - style: IconButton.styleFrom( - backgroundColor: detailPanier.aRemise - ? Colors.orange.shade100 - : Colors.grey.shade100, - minimumSize: Size(isMobile ? 32 : 36, isMobile ? 32 : 36), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildInfoCard( + 'Prix unitaire', + '${product.price.toStringAsFixed(2)} MGA', + Icons.attach_money, + Colors.green, + ), + ), + const SizedBox(width: 8), + Expanded( + child: _buildInfoCard( + 'Stock disponible', + '${product.stock ?? 0}', + Icons.inventory, + product.stock != null && product.stock! > 0 + ? Colors.green + : Colors.red, + ), + ), + ], ), - ), + if (product.reference != null && product.reference!.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + 'Référence: ${product.reference}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade600, + fontFamily: 'monospace', + ), + ), + ], + ], ), - // Bouton pour ajouter un cadeau à un autre produit + ), + + const SizedBox(height: 20), + + // Informations de transfert Container( - margin: const EdgeInsets.only(left: 4), - child: IconButton( - icon: Icon( - Icons.add_circle_outline, - size: isMobile ? 16 : 18, - color: Colors.green.shade600, - ), - onPressed: isOutOfStock ? null : () => _showCadeauDialog(product), - tooltip: 'Ajouter un cadeau', - style: IconButton.styleFrom( - backgroundColor: Colors.green.shade50, - minimumSize: Size(isMobile ? 32 : 36, isMobile ? 32 : 36), - ), + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.orange.shade200), ), - ), - ], - ), - const SizedBox(height: 8), - ], - - // Contrôles de quantité - Container( - decoration: BoxDecoration( - color: isOutOfStock - ? Colors.grey.shade100 - : detailPanier?.estCadeau == true - ? Colors.green.shade50 - : Colors.blue.shade50, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: Icon( - Icons.remove, - size: isMobile ? 16 : 18 + child: Column( + children: [ + Row( + children: [ + Icon(Icons.arrow_forward, color: Colors.orange.shade700), + const SizedBox(width: 8), + Text( + 'Informations de transfert', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: Colors.orange.shade700, + ), + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _buildTransferStep( + 'DE', + pointDeVenteSource ?? 'Chargement...', + Icons.store_outlined, + Colors.red.shade600, + ), + ), + Container( + margin: const EdgeInsets.symmetric(horizontal: 8), + child: Icon( + Icons.arrow_forward, + color: Colors.orange.shade700, + size: 24, + ), + ), + Expanded( + child: _buildTransferStep( + 'VERS', + pointDeVenteDestination ?? 'Chargement...', + Icons.store, + Colors.green.shade600, + ), + ), + ], + ), + ], ), - onPressed: isOutOfStock ? null : () { - if (currentQuantity > 0) { - _modifierQuantite(product.id!, currentQuantity - 1); - } - }, ), + + const SizedBox(height: 20), + + // Champ quantité avec design amélioré Text( - currentQuantity.toString(), + 'Quantité à transférer', style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: isMobile ? 12 : 14, + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, ), ), - IconButton( - icon: Icon( - Icons.add, - size: isMobile ? 16 : 18 + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade300), ), - onPressed: isOutOfStock ? null : () { - if (product.stock == null || currentQuantity < product.stock!) { - if (currentQuantity == 0) { - _ajouterAuPanier(product, 1); - } else { - _modifierQuantite(product.id!, currentQuantity + 1); - } - } else { - Get.snackbar( - 'Stock insuffisant', - 'Quantité demandée non disponible', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, - ); - } - }, + child: Row( + children: [ + IconButton( + onPressed: () { + int currentQty = int.tryParse(quantiteController.text) ?? 1; + if (currentQty > 1) { + quantiteController.text = (currentQty - 1).toString(); + } + }, + icon: Icon(Icons.remove, color: Colors.grey.shade600), + ), + Expanded( + child: TextFormField( + controller: quantiteController, + decoration: const InputDecoration( + border: InputBorder.none, + contentPadding: EdgeInsets.symmetric(horizontal: 16), + hintText: 'Quantité', + ), + textAlign: TextAlign.center, + keyboardType: TextInputType.number, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Veuillez entrer une quantité'; + } + final qty = int.tryParse(value) ?? 0; + if (qty <= 0) { + return 'Quantité invalide'; + } + if (product.stock != null && qty > product.stock!) { + return 'Quantité supérieure au stock disponible'; + } + return null; + }, + ), + ), + IconButton( + onPressed: () { + int currentQty = int.tryParse(quantiteController.text) ?? 1; + int maxStock = product.stock ?? 999; + if (currentQty < maxStock) { + quantiteController.text = (currentQty + 1).toString(); + } + }, + icon: Icon(Icons.add, color: Colors.grey.shade600), + ), + ], + ), + ), + + + + // Boutons d'action avec design moderne + Row( + children: [ + Expanded( + child: TextButton( + onPressed: () => Navigator.pop(context), + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.shade300), + ), + ), + child: Text( + 'Annuler', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + flex: 2, + child: ElevatedButton.icon( + onPressed: () async { + if (!_formKey.currentState!.validate()) return; + + final qty = int.tryParse(quantiteController.text) ?? 0; + if (qty <= 0) { + Get.snackbar( + 'Erreur', + 'Quantité invalide', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + return; + } + + try { + setState(() => _isLoading = true); + Navigator.pop(context); + + await _appDatabase.createDemandeTransfert( + produitId: product.id!, + pointDeVenteSourceId: product.pointDeVenteId!, + pointDeVenteDestinationId: _userController.pointDeVenteId, + demandeurId: _userController.userId, + quantite: qty, + notes: notesController.text.isNotEmpty + ? notesController.text + : 'Demande de transfert depuis l\'application mobile', + ); + + Get.snackbar( + 'Demande envoyée ✅', + 'Votre demande de transfert de $qty unité(s) a été enregistrée et sera traitée prochainement.', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.green, + colorText: Colors.white, + duration: const Duration(seconds: 4), + icon: const Icon(Icons.check_circle, color: Colors.white), + ); + } catch (e) { + Get.snackbar( + 'Erreur', + 'Impossible d\'envoyer la demande: ${e.toString()}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + duration: const Duration(seconds: 4), + ); + } finally { + setState(() => _isLoading = false); + } + }, + icon: const Icon(Icons.send, color: Colors.white), + label: const Text( + 'Envoyer la demande', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade600, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 2, + ), + ), + ), + ], ), ], ), ), - ], - ), - ], + ), + ], + ), ), ), ), ); } +// 🎨 Widget pour les cartes d'information +Widget _buildInfoCard(String label, String value, IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Icon(icon, color: color, size: 20), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + fontWeight: FontWeight.w500, + ), + textAlign: TextAlign.center, + ), + Text( + value, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: color, + ), + textAlign: TextAlign.center, + ), + ], + ), + ); +} + +// 🎨 Widget pour les étapes de transfert +Widget _buildTransferStep(String label, String pointDeVente, IconData icon, Color color) { + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + ), + child: Icon(icon, color: color, size: 16), + ), + const SizedBox(height: 6), + Text( + label, + style: TextStyle( + fontSize: 10, + color: Colors.grey.shade600, + fontWeight: FontWeight.bold, + ), + ), + Text( + pointDeVente, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: color, + ), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); +} + +// 🎨 BOUTON AMÉLIORÉ dans le widget principal +// Remplacez le bouton "Demander transfert" existant par celui-ci : void _showCartBottomSheet() { final isMobile = MediaQuery.of(context).size.width < 600; @@ -2484,46 +3651,115 @@ Widget _buildProductListItem(Product product, int quantity, bool isMobile) { strokeWidth: 2, color: Colors.white, ), - ) - : Text( - isMobile ? 'Valider' : 'Valider la Commande', - style: TextStyle(fontSize: isMobile ? 14 : 16), + ) + : Text( + isMobile ? 'Valider' : 'Valider la Commande', + style: TextStyle(fontSize: isMobile ? 14 : 16), + ), + ), + ); + } + + // 🎯 MODIFIÉ: Validation finale avant soumission +Future _submitOrder() async { + // Vérification panier vide + final itemsInCart = _panierDetails.entries.where((e) => e.value.quantite > 0).toList(); + if (itemsInCart.isEmpty) { + Get.snackbar( + 'Panier vide', + 'Veuillez ajouter des produits à votre commande', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + _showCartBottomSheet(); + return; + } + + // 🔒 VALIDATION SÉCURITÉ FINALE: Vérifier tous les produits du panier + if (!_isUserSuperAdmin()) { + final produitsNonAutorises = []; + + for (final entry in itemsInCart) { + final product = _products.firstWhere((p) => p.id == entry.key); + if (product.pointDeVenteId != _userController.pointDeVenteId) { + produitsNonAutorises.add(product.name); + } + } + + if (produitsNonAutorises.isNotEmpty) { + Get.dialog( + AlertDialog( + title: Row( + children: [ + Icon(Icons.security, color: Colors.red.shade600), + const SizedBox(width: 8), + const Text('Commande non autorisée'), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Les produits suivants ne sont pas autorisés pour votre point de vente:'), + const SizedBox(height: 8), + ...produitsNonAutorises.map((nom) => Padding( + padding: const EdgeInsets.only(left: 16, top: 4), + child: Text('• $nom', style: TextStyle(color: Colors.red.shade700)), + )), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(Icons.info_outline, color: Colors.orange.shade700, size: 16), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Contactez un administrateur pour commander ces produits.', + style: TextStyle( + fontSize: 12, + color: Colors.orange.shade700, + ), + ), + ), + ], + ), ), - ), - ); - } - - Future _submitOrder() async { - // Vérifier d'abord si le panier est vide - final itemsInCart = _panierDetails.entries.where((e) => e.value.quantite > 0).toList(); - if (itemsInCart.isEmpty) { - Get.snackbar( - 'Panier vide', - 'Veuillez ajouter des produits à votre commande', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, + ], + ), + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Compris'), + ), + ], + ), ); - _showCartBottomSheet(); return; } + } - // Vérifier les informations client - if (_nomController.text.isEmpty || - _prenomController.text.isEmpty || - _emailController.text.isEmpty || - _telephoneController.text.isEmpty || - _adresseController.text.isEmpty) { - Get.snackbar( - 'Informations manquantes', - 'Veuillez remplir les informations client', - snackPosition: SnackPosition.BOTTOM, - backgroundColor: Colors.red, - colorText: Colors.white, - ); - _showClientFormDialog(); - return; - } + // Vérification informations client + if (_nomController.text.isEmpty || + _prenomController.text.isEmpty || + _emailController.text.isEmpty || + _telephoneController.text.isEmpty || + _adresseController.text.isEmpty) { + Get.snackbar( + 'Informations manquantes', + 'Veuillez remplir les informations client', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: Colors.red, + colorText: Colors.white, + ); + _showClientFormDialog(); + return; + } setState(() { _isLoading = true; @@ -2704,6 +3940,7 @@ void _ajouterCadeauAuPanier(Product produitCadeau, int quantite) { icon: const Icon(Icons.card_giftcard, color: Colors.white), ); } + void _basculerStatutCadeau(int productId) { final detailExistant = _panierDetails[productId]; if (detailExistant == null) return; @@ -2756,146 +3993,465 @@ void _basculerStatutCadeau(int productId) { } // 10. Modifier le Widget build pour utiliser le nouveau scan automatique - @override - Widget build(BuildContext context) { - final isMobile = MediaQuery.of(context).size.width < 600; - - return Scaffold( - floatingActionButton: Column( - mainAxisAlignment: MainAxisAlignment.end, + // 8. Modifiez votre méthode build pour inclure les nouvelles cartes d'information +// VERSION OPTIMISÉE DE VOTRE INTERFACE EN-TÊTES ET RECHERCHE + +@override +Widget build(BuildContext context) { + final isMobile = MediaQuery.of(context).size.width < 600; + + return Scaffold( + floatingActionButton: _buildFloatingCartButton(), + appBar: CustomAppBar(title: 'Nouvelle commande'), + drawer: CustomDrawer(), + body: GestureDetector( + onTap: _hideAllSuggestions, + child: Column( children: [ - // Bouton de scan automatique (remplace l'ancien scan IMEI) - FloatingActionButton( - heroTag: "auto_scan", - onPressed: _isScanning ? null : _startAutomaticScanning, - backgroundColor: _isScanning ? Colors.grey : Colors.green.shade700, - foregroundColor: Colors.white, - child: _isScanning - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.auto_awesome), + // 🎯 EN-TÊTE OPTIMISÉ + Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + children: [ + // Info utilisateur (toujours visible mais compacte) + _buildCompactUserInfo(), + + // Zone de recherche principale + _buildMainSearchSection(isMobile), + + // Filtres rapides (toujours visibles) + _buildQuickFilters(isMobile), + ], + ), + ), + + // Liste des produits avec indicateur de résultats + Expanded( + child: Column( + children: [ + _buildResultsHeader(), + Expanded(child: _buildProductList()), + ], + ), ), - const SizedBox(height: 10), - // Bouton panier existant - _buildFloatingCartButton(), ], ), - appBar: CustomAppBar(title: 'Nouvelle commande'), - drawer: CustomDrawer(), - body: GestureDetector( - onTap: _hideAllSuggestions, - child: Column( - children: [ - // Section d'information sur le scan automatique (desktop) - if (!isMobile) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: _buildAutoScanInfoCard(), - ), + ), + ); +} - // Section des filtres - if (!isMobile) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: _buildFilterSection(), +// 🎯 INFORMATION UTILISATEUR COMPACTE +Widget _buildCompactUserInfo() { + if (_userController.pointDeVenteId <= 0) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.blue.shade50, + border: Border(bottom: BorderSide(color: Colors.blue.shade100)), + ), + child: Row( + children: [ + Icon(Icons.store, size: 16, color: Colors.blue.shade700), + const SizedBox(width: 8), + Text( + _userController.pointDeVenteDesignation, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: Colors.blue.shade700, + ), + ), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + 'ID: ${_userController.pointDeVenteId}', + style: TextStyle( + fontSize: 10, + color: Colors.blue.shade600, + ), + ), + ), + ], + ), + ); +} + +// 🎯 ZONE DE RECHERCHE PRINCIPALE OPTIMISÉE +Widget _buildMainSearchSection(bool isMobile) { + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + // Recherche principale avec actions intégrées + Row( + children: [ + Expanded( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: TextField( + controller: _searchNameController, + decoration: InputDecoration( + hintText: 'Rechercher par nom, IMEI, référence...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchNameController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchNameController.clear(); + _filterProducts(); + }, + ) + : null, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12 + ), + ), + ), ), + ), + const SizedBox(width: 8), - // Boutons pour mobile - if (isMobile) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), - child: Row( - children: [ - Expanded( - flex: 2, - child: ElevatedButton.icon( - icon: const Icon(Icons.filter_alt), - label: const Text('Filtres'), - onPressed: () { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (context) => SingleChildScrollView( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).viewInsets.bottom, - ), - child: _buildFilterSection(), - ), - ); - }, - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue.shade700, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 1, - child: ElevatedButton.icon( - icon: _isScanning - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : const Icon(Icons.auto_awesome), - label: Text(_isScanning ? 'Scan...' : 'Auto-scan'), - onPressed: _isScanning ? null : _startAutomaticScanning, - style: ElevatedButton.styleFrom( - backgroundColor: _isScanning ? Colors.grey : Colors.green.shade700, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 48), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), + // Bouton Scanner toujours visible + Container( + decoration: BoxDecoration( + color: Colors.green.shade700, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.green.withOpacity(0.3), + blurRadius: 4, + offset: const Offset(0, 2), + ), + ], + ), + child: IconButton( + icon: _isScanning + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, ), - ), + ) + : const Icon(Icons.qr_code_scanner), + color: Colors.white, + onPressed: _isScanning ? null : _startAutomaticScanning, + tooltip: 'Scanner un produit', + ), + ), + + if (!isMobile) ...[ + const SizedBox(width: 8), + // Bouton filtres avancés + Container( + decoration: BoxDecoration( + color: Colors.blue.shade700, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: const Icon(Icons.tune), + color: Colors.white, + onPressed: () => _showAdvancedFiltersDialog(), + tooltip: 'Filtres avancés', + ), + ), + ], + ], + ), + + // Recherche multicritères (desktop uniquement) + if (!isMobile) ...[ + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: TextField( + controller: _searchImeiController, + decoration: InputDecoration( + hintText: 'IMEI', + prefixIcon: const Icon(Icons.phone_android, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), - ], + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + ), ), ), - // Compteur de résultats - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: Colors.blue.shade50, - borderRadius: BorderRadius.circular(20), + const SizedBox(width: 8), + Expanded( + child: TextField( + controller: _searchReferenceController, + decoration: InputDecoration( + hintText: 'Référence', + prefixIcon: const Icon(Icons.qr_code, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, ), - child: Text( - '${_filteredProducts.length} produit(s)', - style: TextStyle( - color: Colors.blue.shade700, - fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + value: _selectedPointDeVente, + decoration: InputDecoration( + hintText: 'Point de vente', + prefixIcon: const Icon(Icons.store, size: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), ), + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('Tous les PV'), + ), + ..._pointsDeVente.map((point) { + return DropdownMenuItem( + value: point['nom'] as String, + child: Text(point['nom'] as String), + ); + }).toList(), + ], + onChanged: (value) { + setState(() { + _selectedPointDeVente = value; + _filterProducts(); + }); + }, ), ), ], - - // Liste des produits - Expanded( - child: _buildProductList(), + ), + ], + ], + ), + ); +} + +// 🎯 FILTRES RAPIDES OPTIMISÉS +Widget _buildQuickFilters(bool isMobile) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.grey.shade50, + border: Border(top: BorderSide(color: Colors.grey.shade200)), + ), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + // Filtre stock + FilterChip( + label: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _showOnlyInStock ? Icons.inventory : Icons.inventory_2, + size: 16, + ), + const SizedBox(width: 4), + Text(_showOnlyInStock ? 'En stock' : 'Tous'), + ], + ), + selected: _showOnlyInStock, + onSelected: (selected) => _toggleStockFilter(), + selectedColor: Colors.green.shade100, + checkmarkColor: Colors.green.shade700, + ), + + const SizedBox(width: 8), + + // Filtre mobile pour ouvrir les filtres avancés + if (isMobile) + ActionChip( + label: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.tune, size: 16), + SizedBox(width: 4), + Text('Filtres'), + ], + ), + onPressed: () => _showMobileFilters(context), + ), + + const SizedBox(width: 8), + + // Bouton reset si filtres actifs + if (_hasActiveFilters()) + ActionChip( + label: const Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.clear, size: 16), + SizedBox(width: 4), + Text('Reset'), + ], + ), + onPressed: _clearFilters, + backgroundColor: Colors.orange.shade100, ), + ], + ), + ), + ); +} + +// 🎯 EN-TÊTE DES RÉSULTATS +Widget _buildResultsHeader() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + border: Border(bottom: BorderSide(color: Colors.grey.shade200)), + ), + child: Row( + children: [ + Text( + '${_filteredProducts.length} produit(s)', + style: TextStyle( + fontWeight: FontWeight.w600, + color: Colors.grey.shade700, + ), + ), + const Spacer(), + + // Indicateurs de filtres actifs + if (_hasActiveFilters()) ...[ + Wrap( + spacing: 4, + children: _getActiveFilterChips(), + ), + ], + ], + ), + ); +} + +// 🎯 MÉTHODES UTILITAIRES +bool _hasActiveFilters() { + return _selectedPointDeVente != null || + _showOnlyInStock || + _searchNameController.text.isNotEmpty || + _searchImeiController.text.isNotEmpty || + _searchReferenceController.text.isNotEmpty; +} + +List _getActiveFilterChips() { + List chips = []; + + if (_selectedPointDeVente != null) { + chips.add(_buildMiniFilterChip('PV: $_selectedPointDeVente')); + } + if (_showOnlyInStock) { + chips.add(_buildMiniFilterChip('En stock')); + } + if (_searchNameController.text.isNotEmpty) { + chips.add(_buildMiniFilterChip('Nom: ${_searchNameController.text}')); + } + if (_searchImeiController.text.isNotEmpty) { + chips.add(_buildMiniFilterChip('IMEI: ${_searchImeiController.text}')); + } + if (_searchReferenceController.text.isNotEmpty) { + chips.add(_buildMiniFilterChip('Réf: ${_searchReferenceController.text}')); + } + + return chips; +} + +Widget _buildMiniFilterChip(String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blue.shade100, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.blue.shade300), + ), + child: Text( + label, + style: TextStyle( + fontSize: 10, + color: Colors.blue.shade700, + fontWeight: FontWeight.w500, + ), + ), + ); +} + +// 🎯 DIALOGUE FILTRES AVANCÉS +void _showAdvancedFiltersDialog() { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Filtres avancés'), + content: SizedBox( + width: 400, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Tous vos filtres existants ici + _buildPointDeVenteFilter(), + const SizedBox(height: 16), + // Autres filtres selon vos besoins ], ), ), - ); - } + actions: [ + TextButton( + onPressed: () => Get.back(), + child: const Text('Fermer'), + ), + ElevatedButton( + onPressed: () { + _filterProducts(); + Get.back(); + }, + child: const Text('Appliquer'), + ), + ], + ), + ); +} } \ No newline at end of file