14 changed files with 7956 additions and 1397 deletions
File diff suppressed because it is too large
@ -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<GestionTransfertsPage> with TickerProviderStateMixin { |
||||
|
final AppDatabase _appDatabase = AppDatabase.instance; |
||||
|
final UserController _userController = Get.find<UserController>(); |
||||
|
|
||||
|
List<Map<String, dynamic>> _demandes = []; |
||||
|
List<Map<String, dynamic>> _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<void> _loadDemandes() async { |
||||
|
setState(() => _isLoading = true); |
||||
|
try { |
||||
|
List<Map<String, dynamic>> 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<void> _validerDemande(int demandeId, Map<String, dynamic> 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<void> _rejeterDemande(int demandeId, Map<String, dynamic> 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<bool> _showConfirmationDialog(Map<String, dynamic> 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<bool>( |
||||
|
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<void> _showRuptureStockDialog(Map<String, dynamic> 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<String?> _showRejectionDialog() async { |
||||
|
final TextEditingController motifController = TextEditingController(); |
||||
|
|
||||
|
return await showDialog<String>( |
||||
|
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<String, dynamic> 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, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -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<ApprobationSortiesPage> { |
||||
|
final AppDatabase _database = AppDatabase.instance; |
||||
|
final UserController _userController = Get.find<UserController>(); |
||||
|
|
||||
|
List<Map<String, dynamic>> _sortiesEnAttente = []; |
||||
|
bool _isLoading = false; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadSortiesEnAttente(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _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<void> _approuverSortie(Map<String, dynamic> sortie) async { |
||||
|
final confirm = await Get.dialog<bool>( |
||||
|
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<void> _refuserSortie(Map<String, dynamic> sortie) async { |
||||
|
final motifController = TextEditingController(); |
||||
|
|
||||
|
final confirm = await Get.dialog<bool>( |
||||
|
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<String, dynamic> 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), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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<DemandeSortiePersonnellePage> |
||||
|
with TickerProviderStateMixin { |
||||
|
final AppDatabase _database = AppDatabase.instance; |
||||
|
final UserController _userController = Get.find<UserController>(); |
||||
|
|
||||
|
final _formKey = GlobalKey<FormState>(); |
||||
|
final _quantiteController = TextEditingController(text: '1'); |
||||
|
final _motifController = TextEditingController(); |
||||
|
final _notesController = TextEditingController(); |
||||
|
final _searchController = TextEditingController(); |
||||
|
|
||||
|
Product? _selectedProduct; |
||||
|
List<Product> _products = []; |
||||
|
List<Product> _filteredProducts = []; |
||||
|
bool _isLoading = false; |
||||
|
bool _isSearching = false; |
||||
|
|
||||
|
late AnimationController _animationController; |
||||
|
late Animation<double> _fadeAnimation; |
||||
|
late Animation<Offset> _slideAnimation; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_animationController = AnimationController( |
||||
|
duration: const Duration(milliseconds: 800), |
||||
|
vsync: this, |
||||
|
); |
||||
|
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate( |
||||
|
CurvedAnimation(parent: _animationController, curve: Curves.easeInOut), |
||||
|
); |
||||
|
_slideAnimation = Tween<Offset>(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<void> _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<void> _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<bool> _showConfirmationDialog() async { |
||||
|
return await showDialog<bool>( |
||||
|
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(); |
||||
|
} |
||||
|
} |
||||
@ -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<HistoriqueSortiesPersonnellesPage> { |
||||
|
final AppDatabase _database = AppDatabase.instance; |
||||
|
final UserController _userController = Get.find<UserController>(); |
||||
|
|
||||
|
List<Map<String, dynamic>> _historique = []; |
||||
|
String? _filtreStatut; |
||||
|
bool _isLoading = false; |
||||
|
bool _afficherSeulementMesDemandes = false; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadHistorique(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _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<String>( |
||||
|
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<String, dynamic> 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), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
Loading…
Reference in new issue