Compare commits
12 Commits
master
...
20062025_0
| Author | SHA1 | Date |
|---|---|---|
|
|
2af3b01d92 | 6 months ago |
|
|
be8c169ad1 | 6 months ago |
|
|
48ae916f02 | 6 months ago |
|
|
c0bbb0da2b | 6 months ago |
|
|
595b38e9fb | 6 months ago |
|
|
525b09c81f | 6 months ago |
|
|
b5a11aa3c9 | 6 months ago |
|
|
831cce13da | 6 months ago |
|
|
c8fedd08e5 | 6 months ago |
|
|
9eafda610f | 6 months ago |
|
|
2bef06a2fe | 6 months ago |
|
|
57ea91b3d7 | 6 months ago |
76 changed files with 28223 additions and 5030 deletions
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@ -0,0 +1,417 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
|
||||
|
import '../Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
|
||||
|
class ClientFormController extends GetxController { |
||||
|
final _formKey = GlobalKey<FormState>(); |
||||
|
|
||||
|
// Controllers pour les champs |
||||
|
final _nomController = TextEditingController(); |
||||
|
final _prenomController = TextEditingController(); |
||||
|
final _emailController = TextEditingController(); |
||||
|
final _telephoneController = TextEditingController(); |
||||
|
final _adresseController = TextEditingController(); |
||||
|
|
||||
|
// Variables observables pour la recherche |
||||
|
var suggestedClients = <Client>[].obs; |
||||
|
var isSearching = false.obs; |
||||
|
var selectedClient = Rxn<Client>(); |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
_nomController.dispose(); |
||||
|
_prenomController.dispose(); |
||||
|
_emailController.dispose(); |
||||
|
_telephoneController.dispose(); |
||||
|
_adresseController.dispose(); |
||||
|
super.onClose(); |
||||
|
} |
||||
|
|
||||
|
// Méthode pour rechercher les clients existants |
||||
|
Future<void> searchClients(String query) async { |
||||
|
if (query.length < 2) { |
||||
|
suggestedClients.clear(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
isSearching.value = true; |
||||
|
try { |
||||
|
final clients = await AppDatabase.instance.suggestClients(query); |
||||
|
suggestedClients.value = clients; |
||||
|
} catch (e) { |
||||
|
print("Erreur recherche clients: $e"); |
||||
|
suggestedClients.clear(); |
||||
|
} finally { |
||||
|
isSearching.value = false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Méthode pour remplir automatiquement le formulaire |
||||
|
void fillFormWithClient(Client client) { |
||||
|
selectedClient.value = client; |
||||
|
_nomController.text = client.nom; |
||||
|
_prenomController.text = client.prenom; |
||||
|
_emailController.text = client.email; |
||||
|
_telephoneController.text = client.telephone; |
||||
|
_adresseController.text = client.adresse ?? ''; |
||||
|
suggestedClients.clear(); |
||||
|
} |
||||
|
|
||||
|
// Méthode pour vider le formulaire |
||||
|
void clearForm() { |
||||
|
selectedClient.value = null; |
||||
|
_nomController.clear(); |
||||
|
_prenomController.clear(); |
||||
|
_emailController.clear(); |
||||
|
_telephoneController.clear(); |
||||
|
_adresseController.clear(); |
||||
|
suggestedClients.clear(); |
||||
|
} |
||||
|
|
||||
|
// Méthode pour valider et soumettre |
||||
|
Future<void> submitForm() async { |
||||
|
if (!_formKey.currentState!.validate()) return; |
||||
|
|
||||
|
try { |
||||
|
Client clientToUse; |
||||
|
|
||||
|
if (selectedClient.value != null) { |
||||
|
// Utiliser le client existant |
||||
|
clientToUse = selectedClient.value!; |
||||
|
} else { |
||||
|
// Créer un nouveau client |
||||
|
final newClient = Client( |
||||
|
nom: _nomController.text.trim(), |
||||
|
prenom: _prenomController.text.trim(), |
||||
|
email: _emailController.text.trim(), |
||||
|
telephone: _telephoneController.text.trim(), |
||||
|
adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(), |
||||
|
dateCreation: DateTime.now(), |
||||
|
); |
||||
|
|
||||
|
clientToUse = await AppDatabase.instance.createOrGetClient(newClient); |
||||
|
} |
||||
|
|
||||
|
// Procéder avec la commande |
||||
|
Get.back(); |
||||
|
_submitOrderWithClient(clientToUse); |
||||
|
|
||||
|
} catch (e) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Erreur lors de la création/récupération du client: $e', |
||||
|
backgroundColor: Colors.red.shade100, |
||||
|
colorText: Colors.red.shade800, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _submitOrderWithClient(Client client) { |
||||
|
// Votre logique existante pour soumettre la commande |
||||
|
// avec le client fourni |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Widget pour le formulaire avec auto-completion |
||||
|
void _showClientFormDialog() { |
||||
|
final controller = Get.put(ClientFormController()); |
||||
|
|
||||
|
Get.dialog( |
||||
|
AlertDialog( |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.shade100, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Icon(Icons.person_add, color: Colors.blue.shade700), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
const Text('Informations Client'), |
||||
|
const Spacer(), |
||||
|
// Bouton pour vider le formulaire |
||||
|
IconButton( |
||||
|
onPressed: controller.clearForm, |
||||
|
icon: const Icon(Icons.clear), |
||||
|
tooltip: 'Vider le formulaire', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
content: Container( |
||||
|
width: 600, |
||||
|
constraints: const BoxConstraints(maxHeight: 700), |
||||
|
child: SingleChildScrollView( |
||||
|
child: Form( |
||||
|
key: controller._formKey, |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// Section de recherche rapide |
||||
|
_buildSearchSection(controller), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Indicateur client sélectionné |
||||
|
Obx(() { |
||||
|
if (controller.selectedClient.value != null) { |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade50, |
||||
|
border: Border.all(color: Colors.green.shade200), |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.check_circle, color: Colors.green.shade600), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
'Client existant sélectionné: ${controller.selectedClient.value!.nomComplet}', |
||||
|
style: TextStyle( |
||||
|
color: Colors.green.shade800, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
return const SizedBox.shrink(); |
||||
|
}), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
// Champs du formulaire |
||||
|
_buildTextFormField( |
||||
|
controller: controller._nomController, |
||||
|
label: 'Nom', |
||||
|
validator: (value) => value?.isEmpty ?? true ? 'Veuillez entrer un nom' : null, |
||||
|
onChanged: (value) { |
||||
|
if (controller.selectedClient.value != null) { |
||||
|
controller.selectedClient.value = null; |
||||
|
} |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
_buildTextFormField( |
||||
|
controller: controller._prenomController, |
||||
|
label: 'Prénom', |
||||
|
validator: (value) => value?.isEmpty ?? true ? 'Veuillez entrer un prénom' : null, |
||||
|
onChanged: (value) { |
||||
|
if (controller.selectedClient.value != null) { |
||||
|
controller.selectedClient.value = null; |
||||
|
} |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
_buildTextFormField( |
||||
|
controller: controller._emailController, |
||||
|
label: 'Email', |
||||
|
keyboardType: TextInputType.emailAddress, |
||||
|
validator: (value) { |
||||
|
if (value?.isEmpty ?? true) return 'Veuillez entrer un email'; |
||||
|
if (!RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$').hasMatch(value!)) { |
||||
|
return 'Email invalide'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
onChanged: (value) { |
||||
|
if (controller.selectedClient.value != null) { |
||||
|
controller.selectedClient.value = null; |
||||
|
} |
||||
|
// Recherche automatique par email |
||||
|
controller.searchClients(value); |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
_buildTextFormField( |
||||
|
controller: controller._telephoneController, |
||||
|
label: 'Téléphone', |
||||
|
keyboardType: TextInputType.phone, |
||||
|
validator: (value) => value?.isEmpty ?? true ? 'Veuillez entrer un téléphone' : null, |
||||
|
onChanged: (value) { |
||||
|
if (controller.selectedClient.value != null) { |
||||
|
controller.selectedClient.value = null; |
||||
|
} |
||||
|
// Recherche automatique par téléphone |
||||
|
controller.searchClients(value); |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
_buildTextFormField( |
||||
|
controller: controller._adresseController, |
||||
|
label: 'Adresse', |
||||
|
maxLines: 2, |
||||
|
validator: (value) => value?.isEmpty ?? true ? 'Veuillez entrer une adresse' : null, |
||||
|
onChanged: (value) { |
||||
|
if (controller.selectedClient.value != null) { |
||||
|
controller.selectedClient.value = null; |
||||
|
} |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
_buildCommercialDropdown(), |
||||
|
|
||||
|
// Liste des suggestions |
||||
|
Obx(() { |
||||
|
if (controller.isSearching.value) { |
||||
|
return const Padding( |
||||
|
padding: EdgeInsets.all(16), |
||||
|
child: Center(child: CircularProgressIndicator()), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
if (controller.suggestedClients.isEmpty) { |
||||
|
return const SizedBox.shrink(); |
||||
|
} |
||||
|
|
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Divider(), |
||||
|
Text( |
||||
|
'Clients trouvés:', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.blue.shade700, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
...controller.suggestedClients.map((client) => |
||||
|
_buildClientSuggestionTile(client, controller), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue.shade800, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), |
||||
|
), |
||||
|
onPressed: controller.submitForm, |
||||
|
child: const Text('Valider la commande'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Widget pour la section de recherche |
||||
|
Widget _buildSearchSection(ClientFormController controller) { |
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Recherche rapide', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.blue.shade700, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
TextFormField( |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Rechercher un client existant', |
||||
|
hintText: 'Nom, prénom, email ou téléphone...', |
||||
|
prefixIcon: const Icon(Icons.search), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey.shade50, |
||||
|
), |
||||
|
onChanged: controller.searchClients, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Widget pour afficher une suggestion de client |
||||
|
Widget _buildClientSuggestionTile(Client client, ClientFormController controller) { |
||||
|
return Card( |
||||
|
margin: const EdgeInsets.only(bottom: 8), |
||||
|
child: ListTile( |
||||
|
leading: CircleAvatar( |
||||
|
backgroundColor: Colors.blue.shade100, |
||||
|
child: Icon(Icons.person, color: Colors.blue.shade700), |
||||
|
), |
||||
|
title: Text( |
||||
|
client.nomComplet, |
||||
|
style: const TextStyle(fontWeight: FontWeight.w500), |
||||
|
), |
||||
|
subtitle: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text('📧 ${client.email}'), |
||||
|
Text('📞 ${client.telephone}'), |
||||
|
if (client.adresse != null && client.adresse!.isNotEmpty) |
||||
|
Text('📍 ${client.adresse}'), |
||||
|
], |
||||
|
), |
||||
|
trailing: ElevatedButton( |
||||
|
onPressed: () => controller.fillFormWithClient(client), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.green, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
||||
|
), |
||||
|
child: const Text('Utiliser'), |
||||
|
), |
||||
|
isThreeLine: true, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Widget helper pour les champs de texte |
||||
|
Widget _buildTextFormField({ |
||||
|
required TextEditingController controller, |
||||
|
required String label, |
||||
|
TextInputType? keyboardType, |
||||
|
String? Function(String?)? validator, |
||||
|
int maxLines = 1, |
||||
|
void Function(String)? onChanged, |
||||
|
}) { |
||||
|
return TextFormField( |
||||
|
controller: controller, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: label, |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey.shade50, |
||||
|
), |
||||
|
keyboardType: keyboardType, |
||||
|
validator: validator, |
||||
|
maxLines: maxLines, |
||||
|
onChanged: onChanged, |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Votre méthode _buildCommercialDropdown existante |
||||
|
Widget _buildCommercialDropdown() { |
||||
|
// Votre implémentation existante |
||||
|
return Container(); // Remplacez par votre code existant |
||||
|
} |
||||
@ -0,0 +1,471 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
import '../Models/client.dart'; |
||||
|
|
||||
|
class ClientFormWidget extends StatefulWidget { |
||||
|
final Function(Client) onClientSelected; |
||||
|
final Client? initialClient; |
||||
|
|
||||
|
const ClientFormWidget({ |
||||
|
Key? key, |
||||
|
required this.onClientSelected, |
||||
|
this.initialClient, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
State<ClientFormWidget> createState() => _ClientFormWidgetState(); |
||||
|
} |
||||
|
|
||||
|
class _ClientFormWidgetState extends State<ClientFormWidget> { |
||||
|
final _formKey = GlobalKey<FormState>(); |
||||
|
final AppDatabase _database = AppDatabase.instance; |
||||
|
|
||||
|
// Contrôleurs de texte |
||||
|
final TextEditingController _nomController = TextEditingController(); |
||||
|
final TextEditingController _prenomController = TextEditingController(); |
||||
|
final TextEditingController _emailController = TextEditingController(); |
||||
|
final TextEditingController _telephoneController = TextEditingController(); |
||||
|
final TextEditingController _adresseController = TextEditingController(); |
||||
|
|
||||
|
// Variables d'état |
||||
|
bool _isLoading = false; |
||||
|
Client? _selectedClient; |
||||
|
List<Client> _suggestions = []; |
||||
|
bool _showSuggestions = false; |
||||
|
String _searchQuery = ''; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
if (widget.initialClient != null) { |
||||
|
_fillClientData(widget.initialClient!); |
||||
|
} |
||||
|
|
||||
|
// Écouter les changements dans les champs pour déclencher la recherche |
||||
|
_emailController.addListener(_onEmailChanged); |
||||
|
_telephoneController.addListener(_onPhoneChanged); |
||||
|
_nomController.addListener(_onNameChanged); |
||||
|
_prenomController.addListener(_onNameChanged); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_nomController.dispose(); |
||||
|
_prenomController.dispose(); |
||||
|
_emailController.dispose(); |
||||
|
_telephoneController.dispose(); |
||||
|
_adresseController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
void _fillClientData(Client client) { |
||||
|
setState(() { |
||||
|
_selectedClient = client; |
||||
|
_nomController.text = client.nom; |
||||
|
_prenomController.text = client.prenom; |
||||
|
_emailController.text = client.email; |
||||
|
_telephoneController.text = client.telephone; |
||||
|
_adresseController.text = client.adresse ?? ''; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
void _clearForm() { |
||||
|
setState(() { |
||||
|
_selectedClient = null; |
||||
|
_nomController.clear(); |
||||
|
_prenomController.clear(); |
||||
|
_emailController.clear(); |
||||
|
_telephoneController.clear(); |
||||
|
_adresseController.clear(); |
||||
|
_suggestions.clear(); |
||||
|
_showSuggestions = false; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Recherche par email |
||||
|
void _onEmailChanged() async { |
||||
|
final email = _emailController.text.trim(); |
||||
|
if (email.length >= 3 && email.contains('@')) { |
||||
|
_searchExistingClient(email: email); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Recherche par téléphone |
||||
|
void _onPhoneChanged() async { |
||||
|
final phone = _telephoneController.text.trim(); |
||||
|
if (phone.length >= 4) { |
||||
|
_searchExistingClient(telephone: phone); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Recherche par nom/prénom |
||||
|
void _onNameChanged() async { |
||||
|
final nom = _nomController.text.trim(); |
||||
|
final prenom = _prenomController.text.trim(); |
||||
|
|
||||
|
if (nom.length >= 2 || prenom.length >= 2) { |
||||
|
final query = '$nom $prenom'.trim(); |
||||
|
if (query.length >= 2) { |
||||
|
_getSuggestions(query); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Rechercher un client existant |
||||
|
Future<void> _searchExistingClient({ |
||||
|
String? email, |
||||
|
String? telephone, |
||||
|
String? nom, |
||||
|
String? prenom, |
||||
|
}) async { |
||||
|
if (_selectedClient != null) return; // Éviter de chercher si un client est déjà sélectionné |
||||
|
|
||||
|
try { |
||||
|
setState(() => _isLoading = true); |
||||
|
|
||||
|
final existingClient = await _database.findExistingClient( |
||||
|
email: email, |
||||
|
telephone: telephone, |
||||
|
nom: nom, |
||||
|
prenom: prenom, |
||||
|
); |
||||
|
|
||||
|
if (existingClient != null && mounted) { |
||||
|
_showClientFoundDialog(existingClient); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('Erreur lors de la recherche: $e'); |
||||
|
} finally { |
||||
|
if (mounted) setState(() => _isLoading = false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Obtenir les suggestions |
||||
|
Future<void> _getSuggestions(String query) async { |
||||
|
if (query.length < 2) { |
||||
|
setState(() { |
||||
|
_suggestions.clear(); |
||||
|
_showSuggestions = false; |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final suggestions = await _database.suggestClients(query); |
||||
|
if (mounted) { |
||||
|
setState(() { |
||||
|
_suggestions = suggestions; |
||||
|
_showSuggestions = suggestions.isNotEmpty; |
||||
|
_searchQuery = query; |
||||
|
}); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
print('Erreur lors de la récupération des suggestions: $e'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Afficher le dialogue de client trouvé |
||||
|
void _showClientFoundDialog(Client client) { |
||||
|
showDialog( |
||||
|
context: context, |
||||
|
barrierDismissible: false, |
||||
|
builder: (context) => AlertDialog( |
||||
|
title: const Text('Client existant trouvé'), |
||||
|
content: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text('Un client avec ces informations existe déjà :'), |
||||
|
const SizedBox(height: 10), |
||||
|
Text('Nom: ${client.nom} ${client.prenom}', style: const TextStyle(fontWeight: FontWeight.bold)), |
||||
|
Text('Email: ${client.email}'), |
||||
|
Text('Téléphone: ${client.telephone}'), |
||||
|
if (client.adresse != null) Text('Adresse: ${client.adresse}'), |
||||
|
const SizedBox(height: 10), |
||||
|
const Text('Voulez-vous utiliser ces informations ?'), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Navigator.of(context).pop(); |
||||
|
// Continuer avec les nouvelles données |
||||
|
}, |
||||
|
child: const Text('Non, créer nouveau'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
Navigator.of(context).pop(); |
||||
|
_fillClientData(client); |
||||
|
}, |
||||
|
child: const Text('Oui, utiliser'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
// Valider et soumettre le formulaire |
||||
|
void _submitForm() async { |
||||
|
if (!_formKey.currentState!.validate()) return; |
||||
|
|
||||
|
try { |
||||
|
setState(() => _isLoading = true); |
||||
|
|
||||
|
Client client; |
||||
|
|
||||
|
if (_selectedClient != null) { |
||||
|
// Utiliser le client existant avec les données mises à jour |
||||
|
client = Client( |
||||
|
id: _selectedClient!.id, |
||||
|
nom: _nomController.text.trim(), |
||||
|
prenom: _prenomController.text.trim(), |
||||
|
email: _emailController.text.trim().toLowerCase(), |
||||
|
telephone: _telephoneController.text.trim(), |
||||
|
adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(), |
||||
|
dateCreation: _selectedClient!.dateCreation, |
||||
|
actif: _selectedClient!.actif, |
||||
|
); |
||||
|
} else { |
||||
|
// Créer un nouveau client |
||||
|
client = Client( |
||||
|
nom: _nomController.text.trim(), |
||||
|
prenom: _prenomController.text.trim(), |
||||
|
email: _emailController.text.trim().toLowerCase(), |
||||
|
telephone: _telephoneController.text.trim(), |
||||
|
adresse: _adresseController.text.trim().isEmpty ? null : _adresseController.text.trim(), |
||||
|
dateCreation: DateTime.now(), |
||||
|
); |
||||
|
|
||||
|
// Utiliser createOrGetClient pour éviter les doublons |
||||
|
client = await _database.createOrGetClient(client); |
||||
|
} |
||||
|
|
||||
|
widget.onClientSelected(client); |
||||
|
|
||||
|
} catch (e) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Erreur lors de la sauvegarde du client: $e', |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} finally { |
||||
|
if (mounted) setState(() => _isLoading = false); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Form( |
||||
|
key: _formKey, |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// En-tête avec bouton de réinitialisation |
||||
|
Row( |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'Informations du client', |
||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
const Spacer(), |
||||
|
if (_selectedClient != null) |
||||
|
Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: const Text( |
||||
|
'Client existant', |
||||
|
style: TextStyle(color: Colors.white, fontSize: 12), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
IconButton( |
||||
|
onPressed: _clearForm, |
||||
|
icon: const Icon(Icons.refresh), |
||||
|
tooltip: 'Nouveau client', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Champs du formulaire |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: TextFormField( |
||||
|
controller: _nomController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Nom *', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
validator: (value) { |
||||
|
if (value == null || value.trim().isEmpty) { |
||||
|
return 'Le nom est requis'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 16), |
||||
|
Expanded( |
||||
|
child: TextFormField( |
||||
|
controller: _prenomController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Prénom *', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
validator: (value) { |
||||
|
if (value == null || value.trim().isEmpty) { |
||||
|
return 'Le prénom est requis'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Email avec indicateur de chargement |
||||
|
Stack( |
||||
|
children: [ |
||||
|
TextFormField( |
||||
|
controller: _emailController, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Email *', |
||||
|
border: const OutlineInputBorder(), |
||||
|
suffixIcon: _isLoading |
||||
|
? const SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator(strokeWidth: 2), |
||||
|
) |
||||
|
: null, |
||||
|
), |
||||
|
keyboardType: TextInputType.emailAddress, |
||||
|
validator: (value) { |
||||
|
if (value == null || value.trim().isEmpty) { |
||||
|
return 'L\'email est requis'; |
||||
|
} |
||||
|
if (!GetUtils.isEmail(value)) { |
||||
|
return 'Email invalide'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
TextFormField( |
||||
|
controller: _telephoneController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Téléphone *', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
keyboardType: TextInputType.phone, |
||||
|
validator: (value) { |
||||
|
if (value == null || value.trim().isEmpty) { |
||||
|
return 'Le téléphone est requis'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
TextFormField( |
||||
|
controller: _adresseController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Adresse', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
maxLines: 2, |
||||
|
), |
||||
|
|
||||
|
// Suggestions |
||||
|
if (_showSuggestions && _suggestions.isNotEmpty) ...[ |
||||
|
const SizedBox(height: 16), |
||||
|
Container( |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all(color: Colors.grey.shade300), |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey.shade100, |
||||
|
borderRadius: const BorderRadius.only( |
||||
|
topLeft: Radius.circular(8), |
||||
|
topRight: Radius.circular(8), |
||||
|
), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
const Icon(Icons.people, size: 16), |
||||
|
const SizedBox(width: 8), |
||||
|
const Text('Clients similaires trouvés:', style: TextStyle(fontWeight: FontWeight.bold)), |
||||
|
const Spacer(), |
||||
|
IconButton( |
||||
|
onPressed: () => setState(() => _showSuggestions = false), |
||||
|
icon: const Icon(Icons.close, size: 16), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
...List.generate(_suggestions.length, (index) { |
||||
|
final suggestion = _suggestions[index]; |
||||
|
return ListTile( |
||||
|
dense: true, |
||||
|
leading: const Icon(Icons.person, size: 20), |
||||
|
title: Text('${suggestion.nom} ${suggestion.prenom}'), |
||||
|
subtitle: Text('${suggestion.email} • ${suggestion.telephone}'), |
||||
|
trailing: ElevatedButton( |
||||
|
onPressed: () => _fillClientData(suggestion), |
||||
|
child: const Text('Utiliser'), |
||||
|
), |
||||
|
); |
||||
|
}), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
|
||||
|
const SizedBox(height: 24), |
||||
|
|
||||
|
// Bouton de soumission |
||||
|
SizedBox( |
||||
|
width: double.infinity, |
||||
|
child: ElevatedButton( |
||||
|
onPressed: _isLoading ? null : _submitForm, |
||||
|
child: _isLoading |
||||
|
? const Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator(strokeWidth: 2), |
||||
|
), |
||||
|
SizedBox(width: 8), |
||||
|
Text('Traitement...'), |
||||
|
], |
||||
|
) |
||||
|
: Text(_selectedClient != null ? 'Utiliser ce client' : 'Créer le client'), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,176 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get/get_core/src/get_main.dart'; |
||||
|
import 'package:get/get_navigation/src/snackbar/snackbar.dart'; |
||||
|
import 'package:youmazgestion/Models/Remise.dart'; |
||||
|
|
||||
|
class DiscountDialog extends StatefulWidget { |
||||
|
final Function(Remise) onDiscountApplied; |
||||
|
|
||||
|
const DiscountDialog({super.key, required this.onDiscountApplied}); |
||||
|
|
||||
|
@override |
||||
|
_DiscountDialogState createState() => _DiscountDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _DiscountDialogState extends State<DiscountDialog> { |
||||
|
RemiseType _selectedType = RemiseType.pourcentage; |
||||
|
final _valueController = TextEditingController(); |
||||
|
final _descriptionController = TextEditingController(); |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_valueController.dispose(); |
||||
|
_descriptionController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
void _applyDiscount() { |
||||
|
final value = double.tryParse(_valueController.text) ?? 0; |
||||
|
|
||||
|
if (value <= 0) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Veuillez entrer une valeur valide', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
if (_selectedType == RemiseType.pourcentage && value > 100) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Le pourcentage ne peut pas dépasser 100%', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
final remise = Remise( |
||||
|
type: _selectedType, |
||||
|
valeur: value, |
||||
|
description: _descriptionController.text, |
||||
|
); |
||||
|
|
||||
|
widget.onDiscountApplied(remise); |
||||
|
Navigator.pop(context); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return AlertDialog( |
||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.local_offer, color: Colors.orange.shade600), |
||||
|
const SizedBox(width: 8), |
||||
|
const Text('Appliquer une remise'), |
||||
|
], |
||||
|
), |
||||
|
content: SingleChildScrollView( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text('Type de remise:', style: TextStyle(fontWeight: FontWeight.w500)), |
||||
|
const SizedBox(height: 8), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: RadioListTile<RemiseType>( |
||||
|
contentPadding: EdgeInsets.zero, |
||||
|
title: const Text('Pourcentage'), |
||||
|
value: RemiseType.pourcentage, |
||||
|
groupValue: _selectedType, |
||||
|
onChanged: (value) => setState(() => _selectedType = value!), |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
child: RadioListTile<RemiseType>( |
||||
|
contentPadding: EdgeInsets.zero, |
||||
|
title: const Text('Montant fixe'), |
||||
|
value: RemiseType.fixe, |
||||
|
groupValue: _selectedType, |
||||
|
onChanged: (value) => setState(() => _selectedType = value!), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
TextField( |
||||
|
controller: _valueController, |
||||
|
keyboardType: TextInputType.numberWithOptions(decimal: true), |
||||
|
decoration: InputDecoration( |
||||
|
labelText: _selectedType == RemiseType.pourcentage |
||||
|
? 'Pourcentage (%)' |
||||
|
: 'Montant (MGA)', |
||||
|
prefixIcon: Icon( |
||||
|
_selectedType == RemiseType.pourcentage |
||||
|
? Icons.percent |
||||
|
: Icons.attach_money, |
||||
|
), |
||||
|
border: const OutlineInputBorder(), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
TextField( |
||||
|
controller: _descriptionController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Motif de la remise (optionnel)', |
||||
|
prefixIcon: Icon(Icons.note), |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
maxLines: 2, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Aperçu de la remise |
||||
|
if (_valueController.text.isNotEmpty) |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.orange.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.orange.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text('Aperçu:', style: TextStyle(fontWeight: FontWeight.bold)), |
||||
|
const SizedBox(height: 4), |
||||
|
Text( |
||||
|
_selectedType == RemiseType.pourcentage |
||||
|
? 'Remise de ${_valueController.text}%' |
||||
|
: 'Remise de ${_valueController.text} MGA', |
||||
|
), |
||||
|
if (_descriptionController.text.isNotEmpty) |
||||
|
Text('Motif: ${_descriptionController.text}'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: _applyDiscount, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.orange.shade600, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
child: const Text('Appliquer'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,349 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get/get_core/src/get_main.dart'; |
||||
|
import 'package:youmazgestion/Models/Remise.dart'; |
||||
|
import 'package:youmazgestion/Models/produit.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
class GiftSelectionDialog extends StatefulWidget { |
||||
|
const GiftSelectionDialog({super.key}); |
||||
|
|
||||
|
@override |
||||
|
_GiftSelectionDialogState createState() => _GiftSelectionDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _GiftSelectionDialogState extends State<GiftSelectionDialog> { |
||||
|
final AppDatabase _database = AppDatabase.instance; |
||||
|
final _searchController = TextEditingController(); |
||||
|
List<Product> _products = []; |
||||
|
List<Product> _filteredProducts = []; |
||||
|
bool _isLoading = true; |
||||
|
String? _selectedCategory; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadProducts(); |
||||
|
_searchController.addListener(_filterProducts); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_searchController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadProducts() async { |
||||
|
try { |
||||
|
final products = await _database.getProducts(); |
||||
|
setState(() { |
||||
|
_products = products.where((p) => p.stock > 0).toList(); // Seulement les produits en stock |
||||
|
_filteredProducts = _products; |
||||
|
_isLoading = false; |
||||
|
}); |
||||
|
} catch (e) { |
||||
|
setState(() => _isLoading = false); |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Impossible de charger les produits', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _filterProducts() { |
||||
|
final query = _searchController.text.toLowerCase(); |
||||
|
setState(() { |
||||
|
_filteredProducts = _products.where((product) { |
||||
|
final matchesSearch = product.name.toLowerCase().contains(query) || |
||||
|
(product.reference?.toLowerCase().contains(query) ?? false) || |
||||
|
(product.imei?.toLowerCase().contains(query) ?? false); |
||||
|
|
||||
|
final matchesCategory = _selectedCategory == null || |
||||
|
product.category == _selectedCategory; |
||||
|
|
||||
|
return matchesSearch && matchesCategory; |
||||
|
}).toList(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
void _selectGift(Product product) { |
||||
|
showDialog( |
||||
|
context: context, |
||||
|
builder: (context) => AlertDialog( |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.card_giftcard, color: Colors.purple.shade600), |
||||
|
const SizedBox(width: 8), |
||||
|
const Text('Confirmer le cadeau'), |
||||
|
], |
||||
|
), |
||||
|
content: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
const Text('Produit sélectionné:', style: TextStyle(fontWeight: FontWeight.bold)), |
||||
|
const SizedBox(height: 8), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.purple.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.purple.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
product.name, |
||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16), |
||||
|
), |
||||
|
if (product.reference != null && product.reference!.isNotEmpty) |
||||
|
Text('Référence: ${product.reference}'), |
||||
|
if (product.category.isNotEmpty) |
||||
|
Text('Catégorie: ${product.category}'), |
||||
|
Text('Prix normal: ${product.price.toStringAsFixed(0)} MGA'), |
||||
|
Text('Stock disponible: ${product.stock}'), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
const Text( |
||||
|
'Ce produit sera ajouté à la commande avec un prix de 0 MGA.', |
||||
|
style: TextStyle(fontSize: 14, color: Colors.grey), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
Navigator.pop(context); // Fermer ce dialogue |
||||
|
Navigator.pop(context, ProduitCadeau(produit: product)); // Retourner le produit |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.purple.shade600, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
child: const Text('Confirmer le cadeau'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final categories = _products.map((p) => p.category).toSet().toList()..sort(); |
||||
|
|
||||
|
return Dialog( |
||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), |
||||
|
child: Container( |
||||
|
width: MediaQuery.of(context).size.width * 0.9, |
||||
|
height: MediaQuery.of(context).size.height * 0.8, |
||||
|
padding: const EdgeInsets.all(20), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// En-tête |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon(Icons.card_giftcard, color: Colors.purple.shade600, size: 28), |
||||
|
const SizedBox(width: 12), |
||||
|
const Expanded( |
||||
|
child: Text( |
||||
|
'Choisir un cadeau', |
||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
icon: const Icon(Icons.close), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Barre de recherche |
||||
|
TextField( |
||||
|
controller: _searchController, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Rechercher un produit', |
||||
|
prefixIcon: const Icon(Icons.search), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
// Filtre par catégorie |
||||
|
Container( |
||||
|
height: 50, |
||||
|
child: ListView( |
||||
|
scrollDirection: Axis.horizontal, |
||||
|
children: [ |
||||
|
FilterChip( |
||||
|
label: const Text('Toutes'), |
||||
|
selected: _selectedCategory == null, |
||||
|
onSelected: (selected) { |
||||
|
setState(() { |
||||
|
_selectedCategory = null; |
||||
|
_filterProducts(); |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
...categories.map((category) => Padding( |
||||
|
padding: const EdgeInsets.only(right: 8), |
||||
|
child: FilterChip( |
||||
|
label: Text(category), |
||||
|
selected: _selectedCategory == category, |
||||
|
onSelected: (selected) { |
||||
|
setState(() { |
||||
|
_selectedCategory = selected ? category : null; |
||||
|
_filterProducts(); |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Liste des produits |
||||
|
Expanded( |
||||
|
child: _isLoading |
||||
|
? const Center(child: CircularProgressIndicator()) |
||||
|
: _filteredProducts.isEmpty |
||||
|
? Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.inventory_2_outlined, |
||||
|
size: 64, |
||||
|
color: Colors.grey.shade400, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Text( |
||||
|
'Aucun produit disponible', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
color: Colors.grey.shade600, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
'Essayez de modifier vos critères de recherche', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.grey.shade500, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: ListView.builder( |
||||
|
itemCount: _filteredProducts.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final product = _filteredProducts[index]; |
||||
|
return Card( |
||||
|
margin: const EdgeInsets.only(bottom: 8), |
||||
|
elevation: 2, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: ListTile( |
||||
|
contentPadding: const EdgeInsets.all(12), |
||||
|
leading: Container( |
||||
|
width: 60, |
||||
|
height: 60, |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.purple.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.purple.shade200), |
||||
|
), |
||||
|
child: product.image != null && product.image!.isNotEmpty |
||||
|
? ClipRRect( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
child: Image.network( |
||||
|
product.image!, |
||||
|
fit: BoxFit.cover, |
||||
|
errorBuilder: (context, error, stackTrace) => |
||||
|
Icon(Icons.image_not_supported, |
||||
|
color: Colors.purple.shade300), |
||||
|
), |
||||
|
) |
||||
|
: Icon(Icons.card_giftcard, |
||||
|
color: Colors.purple.shade400, size: 30), |
||||
|
), |
||||
|
title: Text( |
||||
|
product.name, |
||||
|
style: const TextStyle(fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
subtitle: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
if (product.reference != null && product.reference!.isNotEmpty) |
||||
|
Text('Ref: ${product.reference}'), |
||||
|
Text('Catégorie: ${product.category}'), |
||||
|
Text( |
||||
|
'Prix: ${product.price.toStringAsFixed(0)} MGA', |
||||
|
style: TextStyle( |
||||
|
color: Colors.green.shade600, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
trailing: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade100, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: Text( |
||||
|
'Stock: ${product.stock}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.green.shade700, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
ElevatedButton( |
||||
|
onPressed: () => _selectGift(product), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.purple.shade600, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
), |
||||
|
child: const Text('Choisir', style: TextStyle(fontSize: 12)), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
onTap: () => _selectGift(product), |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,259 @@ |
|||||
|
import 'dart:io'; |
||||
|
import 'dart:ui'; |
||||
|
import 'package:flutter/foundation.dart'; |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:mobile_scanner/mobile_scanner.dart'; |
||||
|
|
||||
|
class ScanQRPage extends StatefulWidget { |
||||
|
const ScanQRPage({super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<ScanQRPage> createState() => _ScanQRPageState(); |
||||
|
} |
||||
|
|
||||
|
class _ScanQRPageState extends State<ScanQRPage> { |
||||
|
MobileScannerController? cameraController; |
||||
|
bool _isScanComplete = false; |
||||
|
String? _scannedData; |
||||
|
bool _hasError = false; |
||||
|
String? _errorMessage; |
||||
|
bool get isMobile => !kIsWeb && (Platform.isAndroid || Platform.isIOS); |
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_initializeController(); |
||||
|
} |
||||
|
|
||||
|
void _initializeController() { |
||||
|
if (!isMobile) { |
||||
|
setState(() { |
||||
|
_hasError = true; |
||||
|
_errorMessage = "Le scanner QR n'est pas disponible sur cette plateforme."; |
||||
|
}); |
||||
|
return; |
||||
|
} |
||||
|
try { |
||||
|
cameraController = MobileScannerController( |
||||
|
detectionSpeed: DetectionSpeed.noDuplicates, |
||||
|
facing: CameraFacing.back, |
||||
|
torchEnabled: false, |
||||
|
); |
||||
|
setState(() { |
||||
|
_hasError = false; |
||||
|
_errorMessage = null; |
||||
|
}); |
||||
|
} catch (e) { |
||||
|
setState(() { |
||||
|
_hasError = true; |
||||
|
_errorMessage = 'Erreur d\'initialisation de la caméra: $e'; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
cameraController?.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: AppBar( |
||||
|
title: const Text('Scanner QR Code'), |
||||
|
actions: _hasError ? [] : [ |
||||
|
if (cameraController != null) ...[ |
||||
|
IconButton( |
||||
|
color: Colors.white, |
||||
|
icon: const Icon(Icons.flash_on, color: Colors.white), |
||||
|
iconSize: 32.0, |
||||
|
onPressed: () => cameraController!.toggleTorch(), |
||||
|
), |
||||
|
IconButton( |
||||
|
color: Colors.white, |
||||
|
icon: const Icon(Icons.flip_camera_ios, color: Colors.white), |
||||
|
iconSize: 32.0, |
||||
|
onPressed: () => cameraController!.switchCamera(), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
body: _hasError ? _buildErrorWidget() : _buildScannerWidget(), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildErrorWidget() { |
||||
|
return Center( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
const Icon( |
||||
|
Icons.error_outline, |
||||
|
size: 64, |
||||
|
color: Colors.red, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
const Text( |
||||
|
'Erreur de caméra', |
||||
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
_errorMessage ?? 'Une erreur s\'est produite', |
||||
|
textAlign: TextAlign.center, |
||||
|
style: const TextStyle(fontSize: 16), |
||||
|
), |
||||
|
const SizedBox(height: 24), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
_initializeController(); |
||||
|
}, |
||||
|
child: const Text('Réessayer'), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
const Text( |
||||
|
'Vérifiez que:\n• Le plugin mobile_scanner est installé\n• Les permissions de caméra sont accordées\n• Votre appareil a une caméra fonctionnelle', |
||||
|
textAlign: TextAlign.center, |
||||
|
style: TextStyle(fontSize: 14, color: Colors.grey), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildScannerWidget() { |
||||
|
if (cameraController == null) { |
||||
|
return const Center(child: CircularProgressIndicator()); |
||||
|
} |
||||
|
|
||||
|
return Stack( |
||||
|
children: [ |
||||
|
MobileScanner( |
||||
|
controller: cameraController!, |
||||
|
onDetect: (capture) { |
||||
|
final List<Barcode> barcodes = capture.barcodes; |
||||
|
for (final barcode in barcodes) { |
||||
|
if (!_isScanComplete && barcode.rawValue != null) { |
||||
|
_isScanComplete = true; |
||||
|
_scannedData = barcode.rawValue; |
||||
|
_showScanResult(context, _scannedData!); |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
errorBuilder: (context, error, child) { |
||||
|
return Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
const Icon(Icons.error, size: 64, color: Colors.red), |
||||
|
const SizedBox(height: 16), |
||||
|
Text('Erreur: ${error.errorDetails?.message ?? 'Erreur inconnue'}'), |
||||
|
const SizedBox(height: 16), |
||||
|
ElevatedButton( |
||||
|
onPressed: () => _initializeController(), |
||||
|
child: const Text('Réessayer'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
CustomPaint( |
||||
|
painter: QrScannerOverlay( |
||||
|
borderColor: Colors.blue.shade800, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showScanResult(BuildContext context, String data) { |
||||
|
showDialog( |
||||
|
context: context, |
||||
|
builder: (context) => AlertDialog( |
||||
|
title: const Text('Résultat du scan'), |
||||
|
content: SelectableText(data), // Permet de sélectionner le texte |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Navigator.pop(context); |
||||
|
setState(() { |
||||
|
_isScanComplete = false; |
||||
|
}); |
||||
|
}, |
||||
|
child: const Text('Fermer'), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () { |
||||
|
Navigator.pop(context); |
||||
|
Navigator.pop(context, data); // Retourner la donnée scannée |
||||
|
}, |
||||
|
child: const Text('Utiliser'), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
).then((_) { |
||||
|
setState(() { |
||||
|
_isScanComplete = false; |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class QrScannerOverlay extends CustomPainter { |
||||
|
final Color borderColor; |
||||
|
|
||||
|
QrScannerOverlay({required this.borderColor}); |
||||
|
|
||||
|
@override |
||||
|
void paint(Canvas canvas, Size size) { |
||||
|
final double width = size.width; |
||||
|
final double height = size.height; |
||||
|
final double borderWidth = 2.0; |
||||
|
final double borderLength = 30.0; |
||||
|
final double areaSize = width * 0.7; |
||||
|
|
||||
|
final Paint backgroundPaint = Paint() |
||||
|
..color = Colors.black.withOpacity(0.4); |
||||
|
canvas.drawRect(Rect.fromLTRB(0, 0, width, height), backgroundPaint); |
||||
|
|
||||
|
final Paint transparentPaint = Paint() |
||||
|
..color = Colors.transparent |
||||
|
..blendMode = BlendMode.clear; |
||||
|
final double areaLeft = (width - areaSize) / 2; |
||||
|
final double areaTop = (height - areaSize) / 2; |
||||
|
canvas.drawRect( |
||||
|
Rect.fromLTRB(areaLeft, areaTop, areaLeft + areaSize, areaTop + areaSize), |
||||
|
transparentPaint, |
||||
|
); |
||||
|
|
||||
|
final Paint borderPaint = Paint() |
||||
|
..color = borderColor |
||||
|
..strokeWidth = borderWidth |
||||
|
..style = PaintingStyle.stroke; |
||||
|
|
||||
|
// Coins du scanner |
||||
|
_drawCorner(canvas, borderPaint, areaLeft, areaTop, borderLength, true, true); |
||||
|
_drawCorner(canvas, borderPaint, areaLeft + areaSize, areaTop, borderLength, false, true); |
||||
|
_drawCorner(canvas, borderPaint, areaLeft, areaTop + areaSize, borderLength, true, false); |
||||
|
_drawCorner(canvas, borderPaint, areaLeft + areaSize, areaTop + areaSize, borderLength, false, false); |
||||
|
} |
||||
|
|
||||
|
void _drawCorner(Canvas canvas, Paint paint, double x, double y, double length, bool isLeft, bool isTop) { |
||||
|
final double horizontalStart = isLeft ? x : x - length; |
||||
|
final double horizontalEnd = isLeft ? x + length : x; |
||||
|
final double verticalStart = isTop ? y : y - length; |
||||
|
final double verticalEnd = isTop ? y + length : y; |
||||
|
|
||||
|
canvas.drawLine(Offset(horizontalStart, y), Offset(horizontalEnd, y), paint); |
||||
|
canvas.drawLine(Offset(x, verticalStart), Offset(x, verticalEnd), paint); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -1,31 +1,121 @@ |
|||||
import 'package:flutter/material.dart'; |
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/controller/userController.dart'; |
||||
|
|
||||
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { |
class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { |
||||
final String title; |
final String title; |
||||
final Widget? subtitle; |
final Widget? subtitle; |
||||
|
final List<Widget>? actions; |
||||
|
final bool automaticallyImplyLeading; |
||||
|
final Color? backgroundColor; |
||||
|
final bool isDesktop; // Add this parameter |
||||
|
|
||||
const CustomAppBar({ |
final UserController userController = Get.put(UserController()); |
||||
|
|
||||
|
CustomAppBar({ |
||||
Key? key, |
Key? key, |
||||
required this.title, |
required this.title, |
||||
this.subtitle, |
this.subtitle, |
||||
|
this.actions, |
||||
|
this.automaticallyImplyLeading = true, |
||||
|
this.backgroundColor, |
||||
|
this.isDesktop = false, // Add this parameter with default value |
||||
}) : super(key: key); |
}) : super(key: key); |
||||
|
|
||||
@override |
@override |
||||
Size get preferredSize => Size.fromHeight(subtitle == null ? 56.0 : 72.0); |
Size get preferredSize => Size.fromHeight(subtitle == null ? 56.0 : 80.0); |
||||
|
|
||||
@override |
@override |
||||
Widget build(BuildContext context) { |
Widget build(BuildContext context) { |
||||
return AppBar( |
return Container( |
||||
title: subtitle == null |
decoration: BoxDecoration( |
||||
? Text(title) |
gradient: LinearGradient( |
||||
: Column( |
begin: Alignment.topLeft, |
||||
crossAxisAlignment: CrossAxisAlignment.start, |
end: Alignment.bottomRight, |
||||
children: [ |
colors: [ |
||||
Text(title, style: TextStyle(fontSize: 20)), |
Colors.blue.shade900, |
||||
subtitle!, |
Colors.blue.shade800, |
||||
|
], |
||||
|
), |
||||
|
boxShadow: [ |
||||
|
BoxShadow( |
||||
|
color: Colors.blue.shade900.withOpacity(0.3), |
||||
|
offset: const Offset(0, 2), |
||||
|
blurRadius: 4, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
child: AppBar( |
||||
|
backgroundColor: backgroundColor ?? Colors.transparent, |
||||
|
elevation: 0, |
||||
|
automaticallyImplyLeading: automaticallyImplyLeading, |
||||
|
centerTitle: false, |
||||
|
iconTheme: const IconThemeData( |
||||
|
color: Colors.white, |
||||
|
size: 24, |
||||
|
), |
||||
|
actions: actions, |
||||
|
title: subtitle == null |
||||
|
? Text( |
||||
|
title, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 20, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
letterSpacing: 0.5, |
||||
|
), |
||||
|
) |
||||
|
: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text( |
||||
|
title, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 20, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
color: Colors.white, |
||||
|
letterSpacing: 0.5, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 2), |
||||
|
Obx(() => Text( |
||||
|
userController.role != 'Super Admin' |
||||
|
? 'Point de vente: ${userController.pointDeVenteDesignation}' |
||||
|
: '', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.w400, |
||||
|
color: Colors.white.withOpacity(0.9), |
||||
|
letterSpacing: 0.3, |
||||
|
), |
||||
|
)), |
||||
|
if (subtitle != null) ...[ |
||||
|
const SizedBox(height: 2), |
||||
|
DefaultTextStyle( |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.white.withOpacity(0.8), |
||||
|
fontWeight: FontWeight.w400, |
||||
|
), |
||||
|
child: subtitle!, |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
flexibleSpace: Container( |
||||
|
decoration: BoxDecoration( |
||||
|
gradient: LinearGradient( |
||||
|
begin: Alignment.topLeft, |
||||
|
end: Alignment.bottomRight, |
||||
|
colors: [ |
||||
|
Colors.blue.shade900, |
||||
|
Colors.blue.shade800, |
||||
], |
], |
||||
), |
), |
||||
// autres propriétés si besoin |
), |
||||
|
), |
||||
|
), |
||||
); |
); |
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,387 @@ |
|||||
|
// Remplacez complètement votre fichier CommandeDetails par celui-ci : |
||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
class CommandeDetails extends StatelessWidget { |
||||
|
final Commande commande; |
||||
|
|
||||
|
const CommandeDetails({required this.commande}); |
||||
|
|
||||
|
Widget _buildTableHeader(String text, {bool isAmount = false}) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
child: Text( |
||||
|
text, |
||||
|
style: const TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
textAlign: isAmount ? TextAlign.right : TextAlign.center, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTableCell(String text, {bool isAmount = false, Color? textColor}) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
child: Text( |
||||
|
text, |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
color: textColor, |
||||
|
), |
||||
|
textAlign: isAmount ? TextAlign.right : TextAlign.center, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildPriceColumn(DetailCommande detail) { |
||||
|
if (detail.aRemise) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.end, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'${detail.prixUnitaire.toStringAsFixed(2)}', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 11, |
||||
|
decoration: TextDecoration.lineThrough, |
||||
|
color: Colors.grey, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 2), |
||||
|
Text( |
||||
|
'${(detail.prixFinal / detail.quantite).toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} else { |
||||
|
return _buildTableCell('${detail.prixUnitaire.toStringAsFixed(2)} MGA', isAmount: true); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Widget _buildRemiseColumn(DetailCommande detail) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
child: detail.aRemise |
||||
|
? Column( |
||||
|
children: [ |
||||
|
Text( |
||||
|
detail.remiseDescription, |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
Text( |
||||
|
'-${detail.montantRemise.toStringAsFixed(0)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 10, |
||||
|
color: Colors.teal.shade700, |
||||
|
), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
], |
||||
|
) |
||||
|
: const Text( |
||||
|
'-', |
||||
|
style: TextStyle(fontSize: 13, color: Colors.grey), |
||||
|
textAlign: TextAlign.center, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildTotalColumn(DetailCommande detail) { |
||||
|
if (detail.aRemise && detail.sousTotal != detail.prixFinal) { |
||||
|
return Padding( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.end, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'${detail.sousTotal.toStringAsFixed(2)}', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 11, |
||||
|
decoration: TextDecoration.lineThrough, |
||||
|
color: Colors.grey, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 2), |
||||
|
Text( |
||||
|
'${detail.prixFinal.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} else { |
||||
|
return _buildTableCell('${detail.prixFinal.toStringAsFixed(2)} MGA', isAmount: true); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return FutureBuilder<List<DetailCommande>>( |
||||
|
future: AppDatabase.instance.getDetailsCommande(commande.id!), |
||||
|
builder: (context, snapshot) { |
||||
|
if (snapshot.connectionState == ConnectionState.waiting) { |
||||
|
return const Center(child: CircularProgressIndicator()); |
||||
|
} |
||||
|
|
||||
|
if (!snapshot.hasData || snapshot.data!.isEmpty) { |
||||
|
return const Text('Aucun détail disponible'); |
||||
|
} |
||||
|
|
||||
|
final details = snapshot.data!; |
||||
|
|
||||
|
// Calculer les totaux |
||||
|
double sousTotal = 0; |
||||
|
double totalRemises = 0; |
||||
|
double totalFinal = 0; |
||||
|
bool hasRemises = false; |
||||
|
|
||||
|
for (final detail in details) { |
||||
|
sousTotal += detail.sousTotal; |
||||
|
totalRemises += detail.montantRemise; |
||||
|
totalFinal += detail.prixFinal; |
||||
|
if (detail.aRemise) hasRemises = true; |
||||
|
} |
||||
|
|
||||
|
return Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: hasRemises ? Colors.orange.shade50 : Colors.blue.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: hasRemises |
||||
|
? Border.all(color: Colors.orange.shade200) |
||||
|
: null, |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
hasRemises ? Icons.discount : Icons.receipt_long, |
||||
|
color: hasRemises ? Colors.orange.shade700 : Colors.blue.shade700, |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Text( |
||||
|
hasRemises ? 'Détails de la commande (avec remises)' : 'Détails de la commande', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 16, |
||||
|
color: hasRemises ? Colors.orange.shade800 : Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
if (hasRemises) ...[ |
||||
|
const Spacer(), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.orange.shade100, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: Text( |
||||
|
'Économies: ${totalRemises.toStringAsFixed(0)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
Container( |
||||
|
decoration: BoxDecoration( |
||||
|
border: Border.all(color: Colors.grey.shade300), |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Table( |
||||
|
children: [ |
||||
|
TableRow( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey.shade100, |
||||
|
), |
||||
|
children: [ |
||||
|
_buildTableHeader('Produit'), |
||||
|
_buildTableHeader('Qté'), |
||||
|
_buildTableHeader('Prix unit.', isAmount: true), |
||||
|
if (hasRemises) _buildTableHeader('Remise'), |
||||
|
_buildTableHeader('Total', isAmount: true), |
||||
|
], |
||||
|
), |
||||
|
...details.map((detail) => TableRow( |
||||
|
decoration: detail.aRemise |
||||
|
? BoxDecoration( |
||||
|
color: const Color.fromARGB(255, 243, 191, 114), |
||||
|
border: Border( |
||||
|
left: BorderSide( |
||||
|
color: Colors.orange.shade300, |
||||
|
width: 3, |
||||
|
), |
||||
|
), |
||||
|
) |
||||
|
: null, |
||||
|
children: [ |
||||
|
Padding( |
||||
|
padding: const EdgeInsets.all(8.0), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
detail.produitNom ?? 'Produit inconnu', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 13, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
if (detail.aRemise) ...[ |
||||
|
const SizedBox(height: 2), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.local_offer, |
||||
|
size: 12, |
||||
|
color: Colors.teal.shade700, |
||||
|
), |
||||
|
const SizedBox(width: 4), |
||||
|
Text( |
||||
|
'Avec remise', |
||||
|
style: TextStyle( |
||||
|
fontSize: 10, |
||||
|
color: Colors.teal.shade700, |
||||
|
fontStyle: FontStyle.italic, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
_buildTableCell('${detail.quantite}'), |
||||
|
_buildPriceColumn(detail), |
||||
|
if (hasRemises) _buildRemiseColumn(detail), |
||||
|
_buildTotalColumn(detail), |
||||
|
], |
||||
|
)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
// Section des totaux |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.green.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// Sous-total si il y a des remises |
||||
|
if (hasRemises) ...[ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'Sous-total:', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'${sousTotal.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.discount, |
||||
|
size: 16, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
const SizedBox(width: 4), |
||||
|
Text( |
||||
|
'Remises totales:', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
Text( |
||||
|
'-${totalRemises.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const Divider(height: 16), |
||||
|
], |
||||
|
|
||||
|
// Total final |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'Total de la commande:', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 16, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'${commande.montantTotal.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 18, |
||||
|
color: Colors.green.shade700, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,213 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
|
||||
|
|
||||
|
//Classe suplementaire |
||||
|
|
||||
|
class CommandeActions extends StatelessWidget { |
||||
|
final Commande commande; |
||||
|
final Function(int, StatutCommande) onStatutChanged; |
||||
|
final Function(Commande) onPaymentSelected; |
||||
|
|
||||
|
|
||||
|
const CommandeActions({ |
||||
|
required this.commande, |
||||
|
required this.onStatutChanged, |
||||
|
required this.onPaymentSelected, |
||||
|
|
||||
|
}); |
||||
|
|
||||
|
|
||||
|
|
||||
|
List<Widget> _buildActionButtons(BuildContext context) { |
||||
|
List<Widget> buttons = []; |
||||
|
|
||||
|
switch (commande.statut) { |
||||
|
case StatutCommande.enAttente: |
||||
|
buttons.addAll([ |
||||
|
|
||||
|
_buildActionButton( |
||||
|
label: 'Confirmer', |
||||
|
icon: Icons.check_circle, |
||||
|
color: Colors.blue, |
||||
|
onPressed: () => onPaymentSelected(commande), |
||||
|
), |
||||
|
_buildActionButton( |
||||
|
label: 'Annuler', |
||||
|
icon: Icons.cancel, |
||||
|
color: Colors.red, |
||||
|
onPressed: () => _showConfirmDialog( |
||||
|
context, |
||||
|
'Annuler la commande', |
||||
|
'Êtes-vous sûr de vouloir annuler cette commande?', |
||||
|
() => onStatutChanged(commande.id!, StatutCommande.annulee), |
||||
|
), |
||||
|
), |
||||
|
]); |
||||
|
break; |
||||
|
|
||||
|
case StatutCommande.confirmee: |
||||
|
buttons.add( |
||||
|
Container( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade100, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.green.shade300), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Icon(Icons.check_circle, |
||||
|
color: Colors.green.shade600, size: 16), |
||||
|
const SizedBox(width: 8), |
||||
|
Text( |
||||
|
'Commande confirmée', |
||||
|
style: TextStyle( |
||||
|
color: Colors.green.shade700, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
break; |
||||
|
|
||||
|
case StatutCommande.annulee: |
||||
|
buttons.add( |
||||
|
Container( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.red.shade100, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.red.shade300), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Icon(Icons.cancel, color: Colors.red.shade600, size: 16), |
||||
|
const SizedBox(width: 8), |
||||
|
Text( |
||||
|
'Commande annulée', |
||||
|
style: TextStyle( |
||||
|
color: Colors.red.shade700, |
||||
|
fontWeight: FontWeight.w600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
return buttons; |
||||
|
} |
||||
|
|
||||
|
Widget _buildActionButton({ |
||||
|
required String label, |
||||
|
required IconData icon, |
||||
|
required Color color, |
||||
|
required VoidCallback onPressed, |
||||
|
}) { |
||||
|
return ElevatedButton.icon( |
||||
|
onPressed: onPressed, |
||||
|
icon: Icon(icon, size: 16), |
||||
|
label: Text(label), |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: color, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
elevation: 2, |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _showConfirmDialog( |
||||
|
BuildContext context, |
||||
|
String title, |
||||
|
String content, |
||||
|
VoidCallback onConfirm, |
||||
|
) { |
||||
|
showDialog( |
||||
|
context: context, |
||||
|
builder: (BuildContext context) { |
||||
|
return AlertDialog( |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.help_outline, |
||||
|
color: Colors.blue.shade600, |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Text( |
||||
|
title, |
||||
|
style: const TextStyle(fontSize: 18), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
content: Text(content), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.of(context).pop(), |
||||
|
child: Text( |
||||
|
'Annuler', |
||||
|
style: TextStyle(color: Colors.grey.shade600), |
||||
|
), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
Navigator.of(context).pop(); |
||||
|
onConfirm(); |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue.shade600, |
||||
|
foregroundColor: Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
), |
||||
|
child: const Text('Confirmer'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
}, |
||||
|
); |
||||
|
} |
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.grey.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'Actions sur la commande', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 16, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
Wrap( |
||||
|
spacing: 8, |
||||
|
runSpacing: 8, |
||||
|
children: _buildActionButtons(context), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,189 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
|
||||
|
|
||||
|
// Dialog pour la remise |
||||
|
class DiscountDialog extends StatefulWidget { |
||||
|
final Commande commande; |
||||
|
|
||||
|
const DiscountDialog({super.key, required this.commande}); |
||||
|
|
||||
|
@override |
||||
|
_DiscountDialogState createState() => _DiscountDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _DiscountDialogState extends State<DiscountDialog> { |
||||
|
final _pourcentageController = TextEditingController(); |
||||
|
final _montantController = TextEditingController(); |
||||
|
bool _isPercentage = true; |
||||
|
double _montantFinal = 0; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_montantFinal = widget.commande.montantTotal; |
||||
|
} |
||||
|
|
||||
|
void _calculateDiscount() { |
||||
|
double discount = 0; |
||||
|
|
||||
|
if (_isPercentage) { |
||||
|
final percentage = double.tryParse(_pourcentageController.text) ?? 0; |
||||
|
discount = (widget.commande.montantTotal * percentage) / 100; |
||||
|
} else { |
||||
|
discount = double.tryParse(_montantController.text) ?? 0; |
||||
|
} |
||||
|
|
||||
|
setState(() { |
||||
|
_montantFinal = widget.commande.montantTotal - discount; |
||||
|
if (_montantFinal < 0) _montantFinal = 0; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return AlertDialog( |
||||
|
title: const Text('Appliquer une remise'), |
||||
|
content: SingleChildScrollView( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Text('Montant original: ${widget.commande.montantTotal.toStringAsFixed(2)} MGA'), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Choix du type de remise |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: RadioListTile<bool>( |
||||
|
title: const Text('Pourcentage'), |
||||
|
value: true, |
||||
|
groupValue: _isPercentage, |
||||
|
onChanged: (value) { |
||||
|
setState(() { |
||||
|
_isPercentage = value!; |
||||
|
_calculateDiscount(); |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
child: RadioListTile<bool>( |
||||
|
title: const Text('Montant fixe'), |
||||
|
value: false, |
||||
|
groupValue: _isPercentage, |
||||
|
onChanged: (value) { |
||||
|
setState(() { |
||||
|
_isPercentage = value!; |
||||
|
_calculateDiscount(); |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
if (_isPercentage) |
||||
|
TextField( |
||||
|
controller: _pourcentageController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Pourcentage de remise', |
||||
|
suffixText: '%', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
keyboardType: TextInputType.number, |
||||
|
onChanged: (value) => _calculateDiscount(), |
||||
|
) |
||||
|
else |
||||
|
TextField( |
||||
|
controller: _montantController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Montant de remise', |
||||
|
suffixText: 'MGA', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
keyboardType: TextInputType.number, |
||||
|
onChanged: (value) => _calculateDiscount(), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.blue.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text('Montant final:'), |
||||
|
Text( |
||||
|
'${_montantFinal.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 16, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
if (_montantFinal < widget.commande.montantTotal) |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text('Économie:'), |
||||
|
Text( |
||||
|
'${(widget.commande.montantTotal - _montantFinal).toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle( |
||||
|
color: Colors.green, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: _montantFinal < widget.commande.montantTotal |
||||
|
? () { |
||||
|
final pourcentage = _isPercentage |
||||
|
? double.tryParse(_pourcentageController.text) |
||||
|
: null; |
||||
|
final montant = !_isPercentage |
||||
|
? double.tryParse(_montantController.text) |
||||
|
: null; |
||||
|
|
||||
|
Navigator.pop(context, { |
||||
|
'pourcentage': pourcentage, |
||||
|
'montant': montant, |
||||
|
'montantFinal': _montantFinal, |
||||
|
}); |
||||
|
} |
||||
|
: null, |
||||
|
child: const Text('Appliquer'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_pourcentageController.dispose(); |
||||
|
_montantController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,136 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
import 'package:youmazgestion/Models/produit.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
|
||||
|
// Dialog pour sélectionner un cadeau |
||||
|
class GiftSelectionDialog extends StatefulWidget { |
||||
|
final Commande commande; |
||||
|
|
||||
|
const GiftSelectionDialog({super.key, required this.commande}); |
||||
|
|
||||
|
@override |
||||
|
_GiftSelectionDialogState createState() => _GiftSelectionDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _GiftSelectionDialogState extends State<GiftSelectionDialog> { |
||||
|
List<Product> _products = []; |
||||
|
List<Product> _filteredProducts = []; |
||||
|
final _searchController = TextEditingController(); |
||||
|
Product? _selectedProduct; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadProducts(); |
||||
|
_searchController.addListener(_filterProducts); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadProducts() async { |
||||
|
final products = await AppDatabase.instance.getProducts(); |
||||
|
setState(() { |
||||
|
_products = products.where((p) => p.stock > 0).toList(); |
||||
|
_filteredProducts = _products; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
void _filterProducts() { |
||||
|
final query = _searchController.text.toLowerCase(); |
||||
|
setState(() { |
||||
|
_filteredProducts = _products.where((product) { |
||||
|
return product.name.toLowerCase().contains(query) || |
||||
|
(product.reference?.toLowerCase().contains(query) ?? false) || |
||||
|
(product.category.toLowerCase().contains(query)); |
||||
|
}).toList(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return AlertDialog( |
||||
|
title: const Text('Sélectionner un cadeau'), |
||||
|
content: SizedBox( |
||||
|
width: double.maxFinite, |
||||
|
height: 400, |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
TextField( |
||||
|
controller: _searchController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Rechercher un produit', |
||||
|
prefixIcon: Icon(Icons.search), |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
Expanded( |
||||
|
child: ListView.builder( |
||||
|
itemCount: _filteredProducts.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final product = _filteredProducts[index]; |
||||
|
return Card( |
||||
|
child: ListTile( |
||||
|
leading: product.image != null |
||||
|
? Image.network( |
||||
|
product.image!, |
||||
|
width: 50, |
||||
|
height: 50, |
||||
|
fit: BoxFit.cover, |
||||
|
errorBuilder: (context, error, stackTrace) => |
||||
|
const Icon(Icons.image_not_supported), |
||||
|
) |
||||
|
: const Icon(Icons.phone_android), |
||||
|
title: Text(product.name), |
||||
|
subtitle: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text('Catégorie: ${product.category}'), |
||||
|
Text('Stock: ${product.stock}'), |
||||
|
if (product.reference != null) |
||||
|
Text('Réf: ${product.reference}'), |
||||
|
], |
||||
|
), |
||||
|
trailing: Radio<Product>( |
||||
|
value: product, |
||||
|
groupValue: _selectedProduct, |
||||
|
onChanged: (value) { |
||||
|
setState(() { |
||||
|
_selectedProduct = value; |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
onTap: () { |
||||
|
setState(() { |
||||
|
_selectedProduct = product; |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: _selectedProduct != null |
||||
|
? () => Navigator.pop(context, _selectedProduct) |
||||
|
: null, |
||||
|
child: const Text('Ajouter le cadeau'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_searchController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,234 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
class PasswordVerificationDialog extends StatefulWidget { |
||||
|
final String title; |
||||
|
final String message; |
||||
|
final Function(String) onPasswordVerified; |
||||
|
|
||||
|
const PasswordVerificationDialog({ |
||||
|
Key? key, |
||||
|
required this.title, |
||||
|
required this.message, |
||||
|
required this.onPasswordVerified, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
_PasswordVerificationDialogState createState() => _PasswordVerificationDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _PasswordVerificationDialogState extends State<PasswordVerificationDialog> { |
||||
|
final TextEditingController _passwordController = TextEditingController(); |
||||
|
bool _isPasswordVisible = false; |
||||
|
bool _isLoading = false; |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_passwordController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return AlertDialog( |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(15), |
||||
|
), |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.security, |
||||
|
color: Colors.blue.shade700, |
||||
|
size: 28, |
||||
|
), |
||||
|
const SizedBox(width: 10), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
widget.title, |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.blue.shade700, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
content: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
widget.message, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 14, |
||||
|
color: Colors.black87, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 20), |
||||
|
Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.grey.shade50, |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
border: Border.all(color: Colors.grey.shade300), |
||||
|
), |
||||
|
child: TextField( |
||||
|
controller: _passwordController, |
||||
|
obscureText: !_isPasswordVisible, |
||||
|
autofocus: true, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Mot de passe', |
||||
|
prefixIcon: Icon( |
||||
|
Icons.lock_outline, |
||||
|
color: Colors.blue.shade600, |
||||
|
), |
||||
|
suffixIcon: IconButton( |
||||
|
icon: Icon( |
||||
|
_isPasswordVisible ? Icons.visibility_off : Icons.visibility, |
||||
|
color: Colors.grey.shade600, |
||||
|
), |
||||
|
onPressed: () { |
||||
|
setState(() { |
||||
|
_isPasswordVisible = !_isPasswordVisible; |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
borderSide: BorderSide.none, |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.white, |
||||
|
contentPadding: const EdgeInsets.symmetric( |
||||
|
horizontal: 16, |
||||
|
vertical: 12, |
||||
|
), |
||||
|
), |
||||
|
onSubmitted: (value) => _verifyPassword(), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 15), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.amber.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.amber.shade200), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.info_outline, |
||||
|
color: Colors.amber.shade700, |
||||
|
size: 20, |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
'Saisissez votre mot de passe pour confirmer cette action', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.amber.shade700, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: _isLoading ? null : () => Navigator.of(context).pop(), |
||||
|
child: Text( |
||||
|
'Annuler', |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey.shade600, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: _isLoading ? null : _verifyPassword, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue.shade700, |
||||
|
foregroundColor: Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), |
||||
|
), |
||||
|
child: _isLoading |
||||
|
? const SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
valueColor: AlwaysStoppedAnimation<Color>(Colors.white), |
||||
|
), |
||||
|
) |
||||
|
: const Text('Vérifier'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
void _verifyPassword() async { |
||||
|
final password = _passwordController.text.trim(); |
||||
|
|
||||
|
if (password.isEmpty) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Veuillez saisir votre mot de passe', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
duration: const Duration(seconds: 2), |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setState(() { |
||||
|
_isLoading = true; |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
final database = AppDatabase.instance; |
||||
|
final isValid = await database.verifyCurrentUserPassword(password); |
||||
|
|
||||
|
setState(() { |
||||
|
_isLoading = false; |
||||
|
}); |
||||
|
|
||||
|
if (isValid) { |
||||
|
Navigator.of(context).pop(); |
||||
|
widget.onPasswordVerified(password); |
||||
|
} else { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Mot de passe incorrect', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
duration: const Duration(seconds: 3), |
||||
|
); |
||||
|
_passwordController.clear(); |
||||
|
} |
||||
|
} catch (e) { |
||||
|
setState(() { |
||||
|
_isLoading = false; |
||||
|
}); |
||||
|
|
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Une erreur est survenue lors de la vérification', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
duration: const Duration(seconds: 3), |
||||
|
); |
||||
|
print("Erreur vérification mot de passe: $e"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
import 'package:youmazgestion/Components/paymentType.dart'; |
||||
|
|
||||
|
class PaymentMethod { |
||||
|
final PaymentType type; |
||||
|
final double amountGiven; |
||||
|
|
||||
|
PaymentMethod({required this.type, this.amountGiven = 0}); |
||||
|
} |
||||
@ -0,0 +1,265 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:get/get_core/src/get_main.dart'; |
||||
|
import 'package:get/get_navigation/src/snackbar/snackbar.dart'; |
||||
|
import 'package:youmazgestion/Components/commandManagementComponents/PaymentMethod.dart'; |
||||
|
import 'package:youmazgestion/Components/paymentType.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
|
||||
|
class PaymentMethodDialog extends StatefulWidget { |
||||
|
final Commande commande; |
||||
|
|
||||
|
const PaymentMethodDialog({super.key, required this.commande}); |
||||
|
|
||||
|
@override |
||||
|
_PaymentMethodDialogState createState() => _PaymentMethodDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _PaymentMethodDialogState extends State<PaymentMethodDialog> { |
||||
|
PaymentType _selectedPayment = PaymentType.cash; |
||||
|
final _amountController = TextEditingController(); |
||||
|
|
||||
|
void _validatePayment() { |
||||
|
final montantFinal = widget.commande.montantTotal; |
||||
|
|
||||
|
if (_selectedPayment == PaymentType.cash) { |
||||
|
final amountGiven = double.tryParse(_amountController.text) ?? 0; |
||||
|
if (amountGiven < montantFinal) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Le montant donné est insuffisant', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
return; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Navigator.pop(context, PaymentMethod( |
||||
|
type: _selectedPayment, |
||||
|
amountGiven: _selectedPayment == PaymentType.cash |
||||
|
? double.parse(_amountController.text) |
||||
|
: montantFinal, |
||||
|
)); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
final montantFinal = widget.commande.montantTotal; |
||||
|
_amountController.text = montantFinal.toStringAsFixed(2); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_amountController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final amount = double.tryParse(_amountController.text) ?? 0; |
||||
|
final montantFinal = widget.commande.montantTotal; |
||||
|
final change = amount - montantFinal; |
||||
|
|
||||
|
return AlertDialog( |
||||
|
title: const Text('Méthode de paiement', style: TextStyle(fontWeight: FontWeight.bold)), |
||||
|
content: SingleChildScrollView( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// Affichage du montant à payer (simplifié) |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.blue.shade200), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text('Montant à payer:', style: TextStyle(fontWeight: FontWeight.bold)), |
||||
|
Text('${montantFinal.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Section Paiement mobile |
||||
|
const Align( |
||||
|
alignment: Alignment.centerLeft, |
||||
|
child: Text('Mobile Money', style: TextStyle(fontWeight: FontWeight.w500)), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: _buildMobileMoneyTile( |
||||
|
title: 'Mvola', |
||||
|
imagePath: 'assets/mvola.jpg', |
||||
|
value: PaymentType.mvola, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: _buildMobileMoneyTile( |
||||
|
title: 'Orange Money', |
||||
|
imagePath: 'assets/Orange_money.png', |
||||
|
value: PaymentType.orange, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: _buildMobileMoneyTile( |
||||
|
title: 'Airtel Money', |
||||
|
imagePath: 'assets/airtel_money.png', |
||||
|
value: PaymentType.airtel, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Section Carte bancaire |
||||
|
const Align( |
||||
|
alignment: Alignment.centerLeft, |
||||
|
child: Text('Carte Bancaire', style: TextStyle(fontWeight: FontWeight.w500)), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
_buildPaymentMethodTile( |
||||
|
title: 'Carte bancaire', |
||||
|
icon: Icons.credit_card, |
||||
|
value: PaymentType.card, |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Section Paiement en liquide |
||||
|
const Align( |
||||
|
alignment: Alignment.centerLeft, |
||||
|
child: Text('Espèces', style: TextStyle(fontWeight: FontWeight.w500)), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
_buildPaymentMethodTile( |
||||
|
title: 'Paiement en liquide', |
||||
|
icon: Icons.money, |
||||
|
value: PaymentType.cash, |
||||
|
), |
||||
|
if (_selectedPayment == PaymentType.cash) ...[ |
||||
|
const SizedBox(height: 12), |
||||
|
TextField( |
||||
|
controller: _amountController, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Montant donné', |
||||
|
prefixText: 'MGA ', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
keyboardType: TextInputType.numberWithOptions(decimal: true), |
||||
|
onChanged: (value) => setState(() {}), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
'Monnaie à rendre: ${change.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: change >= 0 ? Colors.green : Colors.red, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.pop(context), |
||||
|
child: const Text('Annuler', style: TextStyle(color: Colors.grey)), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.blue.shade800, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
onPressed: _validatePayment, |
||||
|
child: const Text('Confirmer'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildMobileMoneyTile({ |
||||
|
required String title, |
||||
|
required String imagePath, |
||||
|
required PaymentType value, |
||||
|
}) { |
||||
|
return Card( |
||||
|
elevation: 2, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
side: BorderSide( |
||||
|
color: _selectedPayment == value ? Colors.blue : Colors.grey.withOpacity(0.2), |
||||
|
width: 2, |
||||
|
), |
||||
|
), |
||||
|
child: InkWell( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
onTap: () => setState(() => _selectedPayment = value), |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
Image.asset( |
||||
|
imagePath, |
||||
|
height: 30, |
||||
|
width: 30, |
||||
|
fit: BoxFit.contain, |
||||
|
errorBuilder: (context, error, stackTrace) => |
||||
|
const Icon(Icons.mobile_friendly, size: 30), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
title, |
||||
|
textAlign: TextAlign.center, |
||||
|
style: const TextStyle(fontSize: 12), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Widget _buildPaymentMethodTile({ |
||||
|
required String title, |
||||
|
required IconData icon, |
||||
|
required PaymentType value, |
||||
|
}) { |
||||
|
return Card( |
||||
|
elevation: 2, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
side: BorderSide( |
||||
|
color: _selectedPayment == value ? Colors.blue : Colors.grey.withOpacity(0.2), |
||||
|
width: 2, |
||||
|
), |
||||
|
), |
||||
|
child: InkWell( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
onTap: () => setState(() => _selectedPayment = value), |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(icon, size: 24), |
||||
|
const SizedBox(width: 12), |
||||
|
Text(title), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
enum PaymentType { |
||||
|
cash, |
||||
|
card, |
||||
|
mvola, |
||||
|
orange, |
||||
|
airtel |
||||
|
} |
||||
@ -0,0 +1,411 @@ |
|||||
|
// Components/newCommandComponents/CadeauDialog.dart |
||||
|
|
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
import 'package:youmazgestion/Models/produit.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
class CadeauDialog extends StatefulWidget { |
||||
|
final Product product; |
||||
|
final int quantite; |
||||
|
final DetailCommande? detailExistant; |
||||
|
|
||||
|
const CadeauDialog({ |
||||
|
Key? key, |
||||
|
required this.product, |
||||
|
required this.quantite, |
||||
|
this.detailExistant, |
||||
|
}) : super(key: key); |
||||
|
|
||||
|
@override |
||||
|
_CadeauDialogState createState() => _CadeauDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _CadeauDialogState extends State<CadeauDialog> { |
||||
|
final AppDatabase _database = AppDatabase.instance; |
||||
|
List<Product> _produitsDisponibles = []; |
||||
|
Product? _produitCadeauSelectionne; |
||||
|
int _quantiteCadeau = 1; |
||||
|
bool _isLoading = true; |
||||
|
String _searchQuery = ''; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadProduitsDisponibles(); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadProduitsDisponibles() async { |
||||
|
try { |
||||
|
final produits = await _database.getProducts(); |
||||
|
setState(() { |
||||
|
_produitsDisponibles = produits.where((p) => |
||||
|
p.id != widget.product.id && // Exclure le produit principal |
||||
|
(p.stock == null || p.stock! > 0) // Seulement les produits en stock |
||||
|
).toList(); |
||||
|
_isLoading = false; |
||||
|
}); |
||||
|
} catch (e) { |
||||
|
setState(() { |
||||
|
_isLoading = false; |
||||
|
}); |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Impossible de charger les produits: $e', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
List<Product> get _produitsFiltres { |
||||
|
if (_searchQuery.isEmpty) { |
||||
|
return _produitsDisponibles; |
||||
|
} |
||||
|
return _produitsDisponibles.where((p) => |
||||
|
p.name.toLowerCase().contains(_searchQuery.toLowerCase()) || |
||||
|
(p.reference?.toLowerCase().contains(_searchQuery.toLowerCase()) ?? false) |
||||
|
).toList(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isMobile = MediaQuery.of(context).size.width < 600; |
||||
|
|
||||
|
return AlertDialog( |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade100, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Icon(Icons.card_giftcard, color: Colors.green.shade700), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Ajouter un cadeau', |
||||
|
style: TextStyle(fontSize: isMobile ? 16 : 18), |
||||
|
), |
||||
|
Text( |
||||
|
'Pour: ${widget.product.name}', |
||||
|
style: TextStyle( |
||||
|
fontSize: isMobile ? 12 : 14, |
||||
|
color: Colors.grey.shade600, |
||||
|
fontWeight: FontWeight.normal, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
content: Container( |
||||
|
width: isMobile ? double.maxFinite : 500, |
||||
|
constraints: BoxConstraints( |
||||
|
maxHeight: MediaQuery.of(context).size.height * 0.7, |
||||
|
), |
||||
|
child: _isLoading |
||||
|
? const Center(child: CircularProgressIndicator()) |
||||
|
: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
// Information sur le produit principal |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.blue.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.blue.shade200), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.shopping_bag, color: Colors.blue.shade700), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Produit acheté', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.blue.shade700, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'${widget.quantite}x ${widget.product.name}', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.w500, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'Prix: ${widget.product.price.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.grey.shade600, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Barre de recherche |
||||
|
TextField( |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Rechercher un produit cadeau', |
||||
|
prefixIcon: Icon(Icons.search, color: Colors.green.shade600), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.green.shade50, |
||||
|
), |
||||
|
onChanged: (value) { |
||||
|
setState(() { |
||||
|
_searchQuery = value; |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Liste des produits disponibles |
||||
|
Expanded( |
||||
|
child: _produitsFiltres.isEmpty |
||||
|
? Center( |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.card_giftcard_outlined, |
||||
|
size: 48, |
||||
|
color: Colors.grey.shade400, |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
Text( |
||||
|
'Aucun produit disponible', |
||||
|
style: TextStyle( |
||||
|
color: Colors.grey.shade600, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: ListView.builder( |
||||
|
itemCount: _produitsFiltres.length, |
||||
|
itemBuilder: (context, index) { |
||||
|
final produit = _produitsFiltres[index]; |
||||
|
final isSelected = _produitCadeauSelectionne?.id == produit.id; |
||||
|
|
||||
|
return Card( |
||||
|
margin: const EdgeInsets.only(bottom: 8), |
||||
|
elevation: isSelected ? 4 : 1, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
side: BorderSide( |
||||
|
color: isSelected |
||||
|
? Colors.green.shade300 |
||||
|
: Colors.grey.shade200, |
||||
|
width: isSelected ? 2 : 1, |
||||
|
), |
||||
|
), |
||||
|
child: ListTile( |
||||
|
contentPadding: const EdgeInsets.all(12), |
||||
|
leading: Container( |
||||
|
width: 40, |
||||
|
height: 40, |
||||
|
decoration: BoxDecoration( |
||||
|
color: isSelected |
||||
|
? Colors.green.shade100 |
||||
|
: Colors.grey.shade100, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Icon( |
||||
|
Icons.card_giftcard, |
||||
|
color: isSelected |
||||
|
? Colors.green.shade700 |
||||
|
: Colors.grey.shade600, |
||||
|
), |
||||
|
), |
||||
|
title: Text( |
||||
|
produit.name, |
||||
|
style: TextStyle( |
||||
|
fontWeight: isSelected |
||||
|
? FontWeight.bold |
||||
|
: FontWeight.normal, |
||||
|
), |
||||
|
), |
||||
|
subtitle: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Prix normal: ${produit.price.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.grey.shade600, |
||||
|
decoration: TextDecoration.lineThrough, |
||||
|
), |
||||
|
), |
||||
|
Row( |
||||
|
children: [ |
||||
|
Icon( |
||||
|
Icons.card_giftcard, |
||||
|
size: 14, |
||||
|
color: Colors.green.shade600, |
||||
|
), |
||||
|
const SizedBox(width: 4), |
||||
|
Text( |
||||
|
'GRATUIT', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.green.shade700, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
if (produit.stock != null) |
||||
|
Text( |
||||
|
'Stock: ${produit.stock}', |
||||
|
style: TextStyle( |
||||
|
fontSize: 11, |
||||
|
color: Colors.grey.shade500, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
trailing: isSelected |
||||
|
? Icon( |
||||
|
Icons.check_circle, |
||||
|
color: Colors.green.shade700, |
||||
|
) |
||||
|
: null, |
||||
|
onTap: () { |
||||
|
setState(() { |
||||
|
_produitCadeauSelectionne = produit; |
||||
|
}); |
||||
|
}, |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Sélection de la quantité si un produit est sélectionné |
||||
|
if (_produitCadeauSelectionne != null) ...[ |
||||
|
const SizedBox(height: 16), |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.green.shade200), |
||||
|
), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
Icon(Icons.card_giftcard, color: Colors.green.shade700), |
||||
|
const SizedBox(width: 8), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
'Quantité de ${_produitCadeauSelectionne!.name}', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.w500, |
||||
|
color: Colors.green.shade700, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
Container( |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.white, |
||||
|
borderRadius: BorderRadius.circular(20), |
||||
|
border: Border.all(color: Colors.green.shade300), |
||||
|
), |
||||
|
child: Row( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
children: [ |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.remove, size: 16), |
||||
|
onPressed: _quantiteCadeau > 1 |
||||
|
? () { |
||||
|
setState(() { |
||||
|
_quantiteCadeau--; |
||||
|
}); |
||||
|
} |
||||
|
: null, |
||||
|
), |
||||
|
Text( |
||||
|
_quantiteCadeau.toString(), |
||||
|
style: const TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
fontSize: 14, |
||||
|
), |
||||
|
), |
||||
|
IconButton( |
||||
|
icon: const Icon(Icons.add, size: 16), |
||||
|
onPressed: () { |
||||
|
final maxStock = _produitCadeauSelectionne!.stock ?? 99; |
||||
|
if (_quantiteCadeau < maxStock) { |
||||
|
setState(() { |
||||
|
_quantiteCadeau++; |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
TextButton( |
||||
|
onPressed: () => Get.back(), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton.icon( |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.green.shade700, |
||||
|
foregroundColor: Colors.white, |
||||
|
padding: EdgeInsets.symmetric( |
||||
|
horizontal: isMobile ? 16 : 20, |
||||
|
vertical: isMobile ? 10 : 12, |
||||
|
), |
||||
|
), |
||||
|
icon: const Icon(Icons.card_giftcard), |
||||
|
label: Text( |
||||
|
isMobile ? 'Offrir' : 'Offrir le cadeau', |
||||
|
style: TextStyle(fontSize: isMobile ? 12 : 14), |
||||
|
), |
||||
|
onPressed: _produitCadeauSelectionne != null |
||||
|
? () { |
||||
|
Get.back(result: { |
||||
|
'produit': _produitCadeauSelectionne!, |
||||
|
'quantite': _quantiteCadeau, |
||||
|
}); |
||||
|
} |
||||
|
: null, |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,331 @@ |
|||||
|
// Components/RemiseDialog.dart |
||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:flutter/services.dart'; |
||||
|
import 'package:youmazgestion/Models/client.dart'; |
||||
|
import 'package:youmazgestion/Models/produit.dart'; |
||||
|
|
||||
|
class RemiseDialog extends StatefulWidget { |
||||
|
final Product product; |
||||
|
final int quantite; |
||||
|
final double prixUnitaire; |
||||
|
final DetailCommande? detailExistant; |
||||
|
|
||||
|
const RemiseDialog({ |
||||
|
super.key, |
||||
|
required this.product, |
||||
|
required this.quantite, |
||||
|
required this.prixUnitaire, |
||||
|
this.detailExistant, |
||||
|
}); |
||||
|
|
||||
|
@override |
||||
|
State<RemiseDialog> createState() => _RemiseDialogState(); |
||||
|
} |
||||
|
|
||||
|
class _RemiseDialogState extends State<RemiseDialog> { |
||||
|
final _formKey = GlobalKey<FormState>(); |
||||
|
final _valeurController = TextEditingController(); |
||||
|
|
||||
|
RemiseType _selectedType = RemiseType.pourcentage; |
||||
|
double _montantRemise = 0.0; |
||||
|
double _prixFinal = 0.0; |
||||
|
late double _sousTotal; |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_sousTotal = widget.quantite * widget.prixUnitaire; |
||||
|
|
||||
|
// Si on modifie une remise existante |
||||
|
if (widget.detailExistant?.aRemise == true) { |
||||
|
_selectedType = widget.detailExistant!.remiseType!; |
||||
|
_valeurController.text = widget.detailExistant!.remiseValeur.toString(); |
||||
|
_calculateRemise(); |
||||
|
} else { |
||||
|
_prixFinal = _sousTotal; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
void _calculateRemise() { |
||||
|
final valeur = double.tryParse(_valeurController.text) ?? 0.0; |
||||
|
|
||||
|
setState(() { |
||||
|
if (_selectedType == RemiseType.pourcentage) { |
||||
|
final pourcentage = valeur.clamp(0.0, 100.0); |
||||
|
_montantRemise = _sousTotal * (pourcentage / 100); |
||||
|
} else { |
||||
|
_montantRemise = valeur.clamp(0.0, _sousTotal); |
||||
|
} |
||||
|
_prixFinal = _sousTotal - _montantRemise; |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
final isMobile = MediaQuery.of(context).size.width < 600; |
||||
|
|
||||
|
return AlertDialog( |
||||
|
title: Row( |
||||
|
children: [ |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(8), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.orange.shade100, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
child: Icon(Icons.discount, color: Colors.orange.shade700), |
||||
|
), |
||||
|
const SizedBox(width: 12), |
||||
|
Expanded( |
||||
|
child: Text( |
||||
|
'Appliquer une remise', |
||||
|
style: TextStyle(fontSize: isMobile ? 16 : 18), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
content: Container( |
||||
|
width: isMobile ? double.maxFinite : 400, |
||||
|
child: Form( |
||||
|
key: _formKey, |
||||
|
child: Column( |
||||
|
mainAxisSize: MainAxisSize.min, |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
// 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: [ |
||||
|
Text( |
||||
|
widget.product.name, |
||||
|
style: const TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 4), |
||||
|
Text( |
||||
|
'Quantité: ${widget.quantite}', |
||||
|
style: const TextStyle(fontSize: 12), |
||||
|
), |
||||
|
Text( |
||||
|
'Prix unitaire: ${widget.prixUnitaire.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle(fontSize: 12), |
||||
|
), |
||||
|
Text( |
||||
|
'Sous-total: ${_sousTotal.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle( |
||||
|
fontSize: 12, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Type de remise |
||||
|
const Text( |
||||
|
'Type de remise:', |
||||
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500), |
||||
|
), |
||||
|
const SizedBox(height: 8), |
||||
|
|
||||
|
Row( |
||||
|
children: [ |
||||
|
Expanded( |
||||
|
child: RadioListTile<RemiseType>( |
||||
|
title: const Text('Pourcentage (%)', style: TextStyle(fontSize: 12)), |
||||
|
value: RemiseType.pourcentage, |
||||
|
groupValue: _selectedType, |
||||
|
onChanged: (value) { |
||||
|
setState(() { |
||||
|
_selectedType = value!; |
||||
|
_calculateRemise(); |
||||
|
}); |
||||
|
}, |
||||
|
contentPadding: EdgeInsets.zero, |
||||
|
dense: true, |
||||
|
), |
||||
|
), |
||||
|
Expanded( |
||||
|
child: RadioListTile<RemiseType>( |
||||
|
title: const Text('Montant (MGA)', style: TextStyle(fontSize: 12)), |
||||
|
value: RemiseType.montant, |
||||
|
groupValue: _selectedType, |
||||
|
onChanged: (value) { |
||||
|
setState(() { |
||||
|
_selectedType = value!; |
||||
|
_calculateRemise(); |
||||
|
}); |
||||
|
}, |
||||
|
contentPadding: EdgeInsets.zero, |
||||
|
dense: true, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Valeur de la remise |
||||
|
TextFormField( |
||||
|
controller: _valeurController, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: _selectedType == RemiseType.pourcentage |
||||
|
? 'Pourcentage (0-100)' |
||||
|
: 'Montant en MGA', |
||||
|
prefixIcon: Icon( |
||||
|
_selectedType == RemiseType.pourcentage |
||||
|
? Icons.percent |
||||
|
: Icons.attach_money, |
||||
|
), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey.shade50, |
||||
|
), |
||||
|
keyboardType: const TextInputType.numberWithOptions(decimal: true), |
||||
|
inputFormatters: [ |
||||
|
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*')), |
||||
|
], |
||||
|
validator: (value) { |
||||
|
if (value == null || value.isEmpty) { |
||||
|
return 'Veuillez entrer une valeur'; |
||||
|
} |
||||
|
final valeur = double.tryParse(value); |
||||
|
if (valeur == null || valeur < 0) { |
||||
|
return 'Valeur invalide'; |
||||
|
} |
||||
|
if (_selectedType == RemiseType.pourcentage && valeur > 100) { |
||||
|
return 'Le pourcentage ne peut pas dépasser 100%'; |
||||
|
} |
||||
|
if (_selectedType == RemiseType.montant && valeur > _sousTotal) { |
||||
|
return 'La remise ne peut pas dépasser le sous-total'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
onChanged: (value) => _calculateRemise(), |
||||
|
), |
||||
|
|
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Aperçu du calcul |
||||
|
Container( |
||||
|
padding: const EdgeInsets.all(12), |
||||
|
decoration: BoxDecoration( |
||||
|
color: Colors.green.shade50, |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
border: Border.all(color: Colors.green.shade200), |
||||
|
), |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.start, |
||||
|
children: [ |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text('Sous-total:', style: TextStyle(fontSize: 12)), |
||||
|
Text( |
||||
|
'${_sousTotal.toStringAsFixed(2)} MGA', |
||||
|
style: const TextStyle(fontSize: 12), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
if (_montantRemise > 0) ...[ |
||||
|
const SizedBox(height: 4), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
Text( |
||||
|
'Remise ${_selectedType == RemiseType.pourcentage ? "(${_valeurController.text}%)" : ""}:', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.orange.shade700, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'-${_montantRemise.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 12, |
||||
|
color: Colors.orange.shade700, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
const Divider(height: 12), |
||||
|
Row( |
||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'Prix final:', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
), |
||||
|
), |
||||
|
Text( |
||||
|
'${_prixFinal.toStringAsFixed(2)} MGA', |
||||
|
style: TextStyle( |
||||
|
fontSize: 14, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Colors.green.shade700, |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
actions: [ |
||||
|
if (widget.detailExistant?.aRemise == true) |
||||
|
TextButton.icon( |
||||
|
onPressed: () => Navigator.of(context).pop('supprimer'), |
||||
|
icon: const Icon(Icons.delete, color: Colors.red), |
||||
|
label: const Text('Supprimer remise', style: TextStyle(color: Colors.red)), |
||||
|
), |
||||
|
TextButton( |
||||
|
onPressed: () => Navigator.of(context).pop(), |
||||
|
child: const Text('Annuler'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () { |
||||
|
if (_formKey.currentState!.validate()) { |
||||
|
final valeur = double.parse(_valeurController.text); |
||||
|
Navigator.of(context).pop({ |
||||
|
'type': _selectedType, |
||||
|
'valeur': valeur, |
||||
|
'montantRemise': _montantRemise, |
||||
|
'prixFinal': _prixFinal, |
||||
|
}); |
||||
|
} |
||||
|
}, |
||||
|
style: ElevatedButton.styleFrom( |
||||
|
backgroundColor: Colors.orange.shade700, |
||||
|
foregroundColor: Colors.white, |
||||
|
), |
||||
|
child: const Text('Appliquer'), |
||||
|
), |
||||
|
], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_valeurController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
enum PaymentType { |
||||
|
cash, |
||||
|
card, |
||||
|
mvola, |
||||
|
orange, |
||||
|
airtel |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
import 'package:youmazgestion/Components/paymentType.dart'; |
||||
|
import 'package:youmazgestion/Models/produit.dart'; |
||||
|
|
||||
|
class Remise { |
||||
|
final RemiseType type; |
||||
|
final double valeur; |
||||
|
final String description; |
||||
|
|
||||
|
Remise({ |
||||
|
required this.type, |
||||
|
required this.valeur, |
||||
|
this.description = '', |
||||
|
}); |
||||
|
|
||||
|
double calculerRemise(double montantOriginal) { |
||||
|
switch (type) { |
||||
|
case RemiseType.pourcentage: |
||||
|
return montantOriginal * (valeur / 100); |
||||
|
case RemiseType.fixe: |
||||
|
return valeur; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
String get libelle { |
||||
|
switch (type) { |
||||
|
case RemiseType.pourcentage: |
||||
|
return '$valeur%'; |
||||
|
case RemiseType.fixe: |
||||
|
return '${valeur.toStringAsFixed(0)} MGA'; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
enum RemiseType { pourcentage, fixe } |
||||
|
|
||||
|
class ProduitCadeau { |
||||
|
final Product produit; |
||||
|
final String motif; |
||||
|
|
||||
|
ProduitCadeau({ |
||||
|
required this.produit, |
||||
|
this.motif = 'Cadeau client', |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Modifiez votre classe PaymentMethod pour inclure la remise |
||||
|
class PaymentMethodEnhanced { |
||||
|
final PaymentType type; |
||||
|
final double amountGiven; |
||||
|
final Remise? remise; |
||||
|
|
||||
|
PaymentMethodEnhanced({ |
||||
|
required this.type, |
||||
|
this.amountGiven = 0, |
||||
|
this.remise, |
||||
|
}); |
||||
|
|
||||
|
double calculerMontantFinal(double montantOriginal) { |
||||
|
if (remise != null) { |
||||
|
return montantOriginal - remise!.calculerRemise(montantOriginal); |
||||
|
} |
||||
|
return montantOriginal; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,36 @@ |
|||||
|
class Pointage { |
||||
|
final int? id; |
||||
|
final String userName; |
||||
|
final String date; |
||||
|
final String heureArrivee; |
||||
|
final String heureDepart; |
||||
|
|
||||
|
Pointage({ |
||||
|
this.id, |
||||
|
required this.userName, |
||||
|
required this.date, |
||||
|
required this.heureArrivee, |
||||
|
required this.heureDepart, |
||||
|
}); |
||||
|
|
||||
|
// Pour SQLite |
||||
|
factory Pointage.fromMap(Map<String, dynamic> map) { |
||||
|
return Pointage( |
||||
|
id: map['id'], |
||||
|
userName: map['userName'] ?? '', |
||||
|
date: map['date'], |
||||
|
heureArrivee: map['heureArrivee'], |
||||
|
heureDepart: map['heureDepart'], |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
Map<String, dynamic> toMap() { |
||||
|
return { |
||||
|
'id': id, |
||||
|
'userName': userName, |
||||
|
'date': date, |
||||
|
'heureArrivee': heureArrivee, |
||||
|
'heureDepart': heureDepart, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,258 @@ |
|||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart'; |
||||
|
|
||||
|
class PermissionCacheService extends GetxController { |
||||
|
static final PermissionCacheService instance = PermissionCacheService._init(); |
||||
|
PermissionCacheService._init(); |
||||
|
|
||||
|
// Cache en mémoire optimisé |
||||
|
final Map<String, Map<String, bool>> _permissionCache = {}; |
||||
|
final Map<String, List<Map<String, dynamic>>> _menuCache = {}; |
||||
|
bool _isLoaded = false; |
||||
|
String _currentUsername = ''; |
||||
|
|
||||
|
/// ✅ OPTIMISÉ: Une seule requête complexe pour charger tout |
||||
|
Future<void> loadUserPermissions(String username) async { |
||||
|
if (_isLoaded && _currentUsername == username && _permissionCache.containsKey(username)) { |
||||
|
print("📋 Permissions déjà en cache pour: $username"); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
print("🔄 Chargement OPTIMISÉ des permissions pour: $username"); |
||||
|
final stopwatch = Stopwatch()..start(); |
||||
|
|
||||
|
try { |
||||
|
final db = AppDatabase.instance; |
||||
|
|
||||
|
// 🚀 UNE SEULE REQUÊTE pour tout récupérer |
||||
|
final userPermissions = await _getUserPermissionsOptimized(db, username); |
||||
|
|
||||
|
// Organiser les données |
||||
|
Map<String, bool> permissions = {}; |
||||
|
Set<Map<String, dynamic>> accessibleMenus = {}; |
||||
|
|
||||
|
for (var row in userPermissions) { |
||||
|
final menuId = row['menu_id'] as int; |
||||
|
final menuName = row['menu_name'] as String; |
||||
|
final menuRoute = row['menu_route'] as String; |
||||
|
final permissionName = row['permission_name'] as String; |
||||
|
|
||||
|
// Ajouter la permission |
||||
|
final key = "${permissionName}_$menuRoute"; |
||||
|
permissions[key] = true; |
||||
|
|
||||
|
// Ajouter le menu aux accessibles |
||||
|
accessibleMenus.add({ |
||||
|
'id': menuId, |
||||
|
'name': menuName, |
||||
|
'route': menuRoute, |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// Mettre en cache |
||||
|
_permissionCache[username] = permissions; |
||||
|
_menuCache[username] = accessibleMenus.toList(); |
||||
|
_currentUsername = username; |
||||
|
_isLoaded = true; |
||||
|
|
||||
|
stopwatch.stop(); |
||||
|
print("✅ Permissions chargées en ${stopwatch.elapsedMilliseconds}ms"); |
||||
|
print(" - ${permissions.length} permissions"); |
||||
|
print(" - ${accessibleMenus.length} menus accessibles"); |
||||
|
|
||||
|
} catch (e) { |
||||
|
stopwatch.stop(); |
||||
|
print("❌ Erreur après ${stopwatch.elapsedMilliseconds}ms: $e"); |
||||
|
rethrow; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// 🚀 NOUVELLE MÉTHODE: Une seule requête optimisée |
||||
|
Future<List<Map<String, dynamic>>> _getUserPermissionsOptimized( |
||||
|
AppDatabase db, String username) async { |
||||
|
|
||||
|
final connection = await db.database; |
||||
|
|
||||
|
final result = await connection.query(''' |
||||
|
SELECT DISTINCT |
||||
|
m.id as menu_id, |
||||
|
m.name as menu_name, |
||||
|
m.route as menu_route, |
||||
|
p.name as permission_name |
||||
|
FROM users u |
||||
|
INNER JOIN roles r ON u.role_id = r.id |
||||
|
INNER JOIN role_menu_permissions rmp ON r.id = rmp.role_id |
||||
|
INNER JOIN menu m ON rmp.menu_id = m.id |
||||
|
INNER JOIN permissions p ON rmp.permission_id = p.id |
||||
|
WHERE u.username = ? |
||||
|
ORDER BY m.name, p.name |
||||
|
''', [username]); |
||||
|
|
||||
|
return result.map((row) => row.fields).toList(); |
||||
|
} |
||||
|
|
||||
|
/// ✅ Vérification rapide depuis le cache |
||||
|
bool hasPermission(String username, String permissionName, String menuRoute) { |
||||
|
final userPermissions = _permissionCache[username]; |
||||
|
if (userPermissions == null) { |
||||
|
print("⚠️ Cache non initialisé pour: $username"); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
final key = "${permissionName}_$menuRoute"; |
||||
|
return userPermissions[key] ?? false; |
||||
|
} |
||||
|
|
||||
|
/// ✅ Récupération rapide des menus |
||||
|
List<Map<String, dynamic>> getUserMenus(String username) { |
||||
|
return _menuCache[username] ?? []; |
||||
|
} |
||||
|
|
||||
|
/// ✅ Vérification d'accès menu |
||||
|
bool hasMenuAccess(String username, String menuRoute) { |
||||
|
final userMenus = _menuCache[username] ?? []; |
||||
|
return userMenus.any((menu) => menu['route'] == menuRoute); |
||||
|
} |
||||
|
|
||||
|
/// ✅ Préchargement asynchrone en arrière-plan |
||||
|
Future<void> preloadUserDataAsync(String username) async { |
||||
|
// Lancer en arrière-plan sans bloquer l'UI |
||||
|
unawaited(_preloadInBackground(username)); |
||||
|
} |
||||
|
|
||||
|
Future<void> _preloadInBackground(String username) async { |
||||
|
try { |
||||
|
print("🔄 Préchargement en arrière-plan pour: $username"); |
||||
|
await loadUserPermissions(username); |
||||
|
print("✅ Préchargement terminé"); |
||||
|
} catch (e) { |
||||
|
print("⚠️ Erreur préchargement: $e"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// ✅ Préchargement synchrone (pour la connexion) |
||||
|
Future<void> preloadUserData(String username) async { |
||||
|
try { |
||||
|
print("🔄 Préchargement synchrone pour: $username"); |
||||
|
await loadUserPermissions(username); |
||||
|
print("✅ Données préchargées avec succès"); |
||||
|
} catch (e) { |
||||
|
print("❌ Erreur lors du préchargement: $e"); |
||||
|
// Ne pas bloquer la connexion |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// ✅ Vider le cache |
||||
|
void clearAllCache() { |
||||
|
_permissionCache.clear(); |
||||
|
_menuCache.clear(); |
||||
|
_isLoaded = false; |
||||
|
_currentUsername = ''; |
||||
|
print("🗑️ Cache vidé complètement"); |
||||
|
} |
||||
|
|
||||
|
/// ✅ Rechargement forcé |
||||
|
Future<void> refreshUserPermissions(String username) async { |
||||
|
_permissionCache.remove(username); |
||||
|
_menuCache.remove(username); |
||||
|
_isLoaded = false; |
||||
|
|
||||
|
await loadUserPermissions(username); |
||||
|
print("🔄 Permissions rechargées pour: $username"); |
||||
|
} |
||||
|
|
||||
|
/// ✅ Status du cache |
||||
|
bool get isLoaded => _isLoaded && _currentUsername.isNotEmpty; |
||||
|
String get currentCachedUser => _currentUsername; |
||||
|
|
||||
|
/// ✅ Statistiques |
||||
|
Map<String, dynamic> getCacheStats() { |
||||
|
return { |
||||
|
'is_loaded': _isLoaded, |
||||
|
'current_user': _currentUsername, |
||||
|
'users_cached': _permissionCache.length, |
||||
|
'total_permissions': _permissionCache.values |
||||
|
.map((perms) => perms.length) |
||||
|
.fold(0, (a, b) => a + b), |
||||
|
'total_menus': _menuCache.values |
||||
|
.map((menus) => menus.length) |
||||
|
.fold(0, (a, b) => a + b), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// ✅ Debug amélioré |
||||
|
void debugPrintCache() { |
||||
|
print("=== DEBUG CACHE OPTIMISÉ ==="); |
||||
|
print("Chargé: $_isLoaded"); |
||||
|
print("Utilisateur actuel: $_currentUsername"); |
||||
|
print("Utilisateurs en cache: ${_permissionCache.keys.toList()}"); |
||||
|
|
||||
|
for (var username in _permissionCache.keys) { |
||||
|
final permissions = _permissionCache[username]!; |
||||
|
final menus = _menuCache[username] ?? []; |
||||
|
print("$username: ${permissions.length} permissions, ${menus.length} menus"); |
||||
|
|
||||
|
// Détail des menus pour debug |
||||
|
for (var menu in menus.take(3)) { |
||||
|
print(" → ${menu['name']} (${menu['route']})"); |
||||
|
} |
||||
|
} |
||||
|
print("============================"); |
||||
|
} |
||||
|
|
||||
|
/// ✅ NOUVEAU: Validation de l'intégrité du cache |
||||
|
Future<bool> validateCacheIntegrity(String username) async { |
||||
|
if (!_permissionCache.containsKey(username)) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final db = AppDatabase.instance; |
||||
|
final connection = await db.database; |
||||
|
|
||||
|
// Vérification rapide: compter les permissions de l'utilisateur |
||||
|
final result = await connection.query(''' |
||||
|
SELECT COUNT(DISTINCT CONCAT(p.name, '_', m.route)) as permission_count |
||||
|
FROM users u |
||||
|
INNER JOIN roles r ON u.role_id = r.id |
||||
|
INNER JOIN role_menu_permissions rmp ON r.id = rmp.role_id |
||||
|
INNER JOIN menu m ON rmp.menu_id = m.id |
||||
|
INNER JOIN permissions p ON rmp.permission_id = p.id |
||||
|
WHERE u.username = ? |
||||
|
''', [username]); |
||||
|
|
||||
|
final dbCount = result.first['permission_count'] as int; |
||||
|
final cacheCount = _permissionCache[username]!.length; |
||||
|
|
||||
|
final isValid = dbCount == cacheCount; |
||||
|
if (!isValid) { |
||||
|
print("⚠️ Cache invalide: DB=$dbCount, Cache=$cacheCount"); |
||||
|
} |
||||
|
|
||||
|
return isValid; |
||||
|
} catch (e) { |
||||
|
print("❌ Erreur validation cache: $e"); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// ✅ NOUVEAU: Rechargement intelligent |
||||
|
Future<void> smartRefresh(String username) async { |
||||
|
final isValid = await validateCacheIntegrity(username); |
||||
|
|
||||
|
if (!isValid) { |
||||
|
print("🔄 Cache invalide, rechargement nécessaire"); |
||||
|
await refreshUserPermissions(username); |
||||
|
} else { |
||||
|
print("✅ Cache valide, pas de rechargement nécessaire"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// ✅ Extension pour éviter l'import de dart:async |
||||
|
void unawaited(Future future) { |
||||
|
// Ignorer le warning sur le Future non attendu |
||||
|
future.catchError((error) { |
||||
|
print("Erreur tâche en arrière-plan: $error"); |
||||
|
}); |
||||
|
} |
||||
@ -0,0 +1,359 @@ |
|||||
|
-- Script SQL pour créer la base de données guycom_database_v1 |
||||
|
-- Création des tables et insertion des données par défaut |
||||
|
|
||||
|
-- ===================================================== |
||||
|
-- CRÉATION DES TABLES |
||||
|
-- ===================================================== |
||||
|
|
||||
|
-- Table permissions |
||||
|
CREATE TABLE `permissions` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`name` varchar(255) NOT NULL, |
||||
|
PRIMARY KEY (`id`), |
||||
|
UNIQUE KEY `name` (`name`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table menu |
||||
|
CREATE TABLE `menu` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`name` varchar(255) NOT NULL, |
||||
|
`route` varchar(255) NOT NULL, |
||||
|
PRIMARY KEY (`id`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=14 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table roles |
||||
|
CREATE TABLE `roles` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`designation` varchar(255) NOT NULL, |
||||
|
PRIMARY KEY (`id`), |
||||
|
UNIQUE KEY `designation` (`designation`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table points_de_vente |
||||
|
CREATE TABLE `points_de_vente` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`nom` varchar(255) NOT NULL, |
||||
|
PRIMARY KEY (`id`), |
||||
|
UNIQUE KEY `nom` (`nom`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=8 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table clients |
||||
|
CREATE TABLE `clients` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`nom` varchar(255) NOT NULL, |
||||
|
`prenom` varchar(255) NOT NULL, |
||||
|
`email` varchar(255) NOT NULL, |
||||
|
`telephone` varchar(255) NOT NULL, |
||||
|
`adresse` varchar(500) DEFAULT NULL, |
||||
|
`dateCreation` datetime NOT NULL, |
||||
|
`actif` tinyint(1) NOT NULL DEFAULT 1, |
||||
|
PRIMARY KEY (`id`), |
||||
|
UNIQUE KEY `email` (`email`), |
||||
|
KEY `idx_clients_email` (`email`), |
||||
|
KEY `idx_clients_telephone` (`telephone`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table users |
||||
|
CREATE TABLE `users` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`name` varchar(255) NOT NULL, |
||||
|
`lastname` varchar(255) NOT NULL, |
||||
|
`email` varchar(255) NOT NULL, |
||||
|
`password` varchar(255) NOT NULL, |
||||
|
`username` varchar(255) NOT NULL, |
||||
|
`role_id` int(11) NOT NULL, |
||||
|
`point_de_vente_id` int(11) DEFAULT NULL, |
||||
|
PRIMARY KEY (`id`), |
||||
|
UNIQUE KEY `email` (`email`), |
||||
|
UNIQUE KEY `username` (`username`), |
||||
|
KEY `role_id` (`role_id`), |
||||
|
KEY `point_de_vente_id` (`point_de_vente_id`), |
||||
|
CONSTRAINT `users_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`), |
||||
|
CONSTRAINT `users_ibfk_2` FOREIGN KEY (`point_de_vente_id`) REFERENCES `points_de_vente` (`id`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table products |
||||
|
CREATE TABLE `products` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`name` varchar(255) NOT NULL, |
||||
|
`price` decimal(10,2) NOT NULL, |
||||
|
`image` varchar(2000) DEFAULT NULL, |
||||
|
`category` varchar(255) NOT NULL, |
||||
|
`stock` int(11) NOT NULL DEFAULT 0, |
||||
|
`description` varchar(1000) DEFAULT NULL, |
||||
|
`qrCode` varchar(500) DEFAULT NULL, |
||||
|
`reference` varchar(255) DEFAULT NULL, |
||||
|
`point_de_vente_id` int(11) DEFAULT NULL, |
||||
|
`marque` varchar(255) DEFAULT NULL, |
||||
|
`ram` varchar(100) DEFAULT NULL, |
||||
|
`memoire_interne` varchar(100) DEFAULT NULL, |
||||
|
`imei` varchar(255) DEFAULT NULL, |
||||
|
PRIMARY KEY (`id`), |
||||
|
UNIQUE KEY `imei` (`imei`), |
||||
|
KEY `point_de_vente_id` (`point_de_vente_id`), |
||||
|
KEY `idx_products_category` (`category`), |
||||
|
KEY `idx_products_reference` (`reference`), |
||||
|
KEY `idx_products_imei` (`imei`), |
||||
|
CONSTRAINT `products_ibfk_1` FOREIGN KEY (`point_de_vente_id`) REFERENCES `points_de_vente` (`id`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=127 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table commandes |
||||
|
CREATE TABLE `commandes` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`clientId` int(11) NOT NULL, |
||||
|
`dateCommande` datetime NOT NULL, |
||||
|
`statut` int(11) NOT NULL DEFAULT 0, |
||||
|
`montantTotal` decimal(10,2) NOT NULL, |
||||
|
`notes` varchar(1000) DEFAULT NULL, |
||||
|
`dateLivraison` datetime DEFAULT NULL, |
||||
|
`commandeurId` int(11) DEFAULT NULL, |
||||
|
`validateurId` int(11) DEFAULT NULL, |
||||
|
PRIMARY KEY (`id`), |
||||
|
KEY `commandeurId` (`commandeurId`), |
||||
|
KEY `validateurId` (`validateurId`), |
||||
|
KEY `idx_commandes_client` (`clientId`), |
||||
|
KEY `idx_commandes_date` (`dateCommande`), |
||||
|
CONSTRAINT `commandes_ibfk_1` FOREIGN KEY (`commandeurId`) REFERENCES `users` (`id`), |
||||
|
CONSTRAINT `commandes_ibfk_2` FOREIGN KEY (`validateurId`) REFERENCES `users` (`id`), |
||||
|
CONSTRAINT `commandes_ibfk_3` FOREIGN KEY (`clientId`) REFERENCES `clients` (`id`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=22 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table details_commandes |
||||
|
CREATE TABLE `details_commandes` ( |
||||
|
`id` int(11) NOT NULL AUTO_INCREMENT, |
||||
|
`commandeId` int(11) NOT NULL, |
||||
|
`produitId` int(11) NOT NULL, |
||||
|
`quantite` int(11) NOT NULL, |
||||
|
`prixUnitaire` decimal(10,2) NOT NULL, |
||||
|
`sousTotal` decimal(10,2) NOT NULL, |
||||
|
`remise_type` enum('pourcentage','montant') DEFAULT NULL, |
||||
|
`remise_valeur` decimal(10,2) DEFAULT 0.00, |
||||
|
`montant_remise` decimal(10,2) DEFAULT 0.00, |
||||
|
`prix_final` decimal(10,2) NOT NULL DEFAULT 0.00, |
||||
|
`est_cadeau` tinyint(1) NOT NULL DEFAULT 0, |
||||
|
PRIMARY KEY (`id`), |
||||
|
KEY `produitId` (`produitId`), |
||||
|
KEY `idx_details_commande` (`commandeId`), |
||||
|
KEY `idx_est_cadeau` (`est_cadeau`), |
||||
|
CONSTRAINT `details_commandes_ibfk_1` FOREIGN KEY (`commandeId`) REFERENCES `commandes` (`id`) ON DELETE CASCADE, |
||||
|
CONSTRAINT `details_commandes_ibfk_2` FOREIGN KEY (`produitId`) REFERENCES `products` (`id`) |
||||
|
) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table role_permissions |
||||
|
CREATE TABLE `role_permissions` ( |
||||
|
`role_id` int(11) NOT NULL, |
||||
|
`permission_id` int(11) NOT NULL, |
||||
|
PRIMARY KEY (`role_id`,`permission_id`), |
||||
|
KEY `permission_id` (`permission_id`), |
||||
|
CONSTRAINT `role_permissions_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE, |
||||
|
CONSTRAINT `role_permissions_ibfk_2` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- Table role_menu_permissions |
||||
|
CREATE TABLE `role_menu_permissions` ( |
||||
|
`role_id` int(11) NOT NULL, |
||||
|
`menu_id` int(11) NOT NULL, |
||||
|
`permission_id` int(11) NOT NULL, |
||||
|
PRIMARY KEY (`role_id`,`menu_id`,`permission_id`), |
||||
|
KEY `menu_id` (`menu_id`), |
||||
|
KEY `permission_id` (`permission_id`), |
||||
|
CONSTRAINT `role_menu_permissions_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE CASCADE, |
||||
|
CONSTRAINT `role_menu_permissions_ibfk_2` FOREIGN KEY (`menu_id`) REFERENCES `menu` (`id`) ON DELETE CASCADE, |
||||
|
CONSTRAINT `role_menu_permissions_ibfk_3` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE CASCADE |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; |
||||
|
|
||||
|
-- ===================================================== |
||||
|
-- INSERTION DES DONNÉES PAR DÉFAUT |
||||
|
-- ===================================================== |
||||
|
|
||||
|
-- Insertion des permissions par défaut |
||||
|
INSERT INTO `permissions` (`name`) VALUES |
||||
|
('view'), |
||||
|
('create'), |
||||
|
('update'), |
||||
|
('delete'), |
||||
|
('admin'), |
||||
|
('manage'), |
||||
|
('read'); |
||||
|
|
||||
|
-- Insertion des menus par défaut |
||||
|
INSERT INTO `menu` (`name`, `route`) VALUES |
||||
|
('Accueil', '/accueil'), |
||||
|
('Ajouter un utilisateur', '/ajouter-utilisateur'), |
||||
|
('Modifier/Supprimer un utilisateur', '/modifier-utilisateur'), |
||||
|
('Ajouter un produit', '/ajouter-produit'), |
||||
|
('Modifier/Supprimer un produit', '/modifier-produit'), |
||||
|
('Bilan', '/bilan'), |
||||
|
('Gérer les rôles', '/gerer-roles'), |
||||
|
('Gestion de stock', '/gestion-stock'), |
||||
|
('Historique', '/historique'), |
||||
|
('Déconnexion', '/deconnexion'), |
||||
|
('Nouvelle commande', '/nouvelle-commande'), |
||||
|
('Gérer les commandes', '/gerer-commandes'), |
||||
|
('Points de vente', '/points-de-vente'); |
||||
|
|
||||
|
-- Insertion des rôles par défaut |
||||
|
INSERT INTO `roles` (`designation`) VALUES |
||||
|
('Super Admin'), |
||||
|
('Admin'), |
||||
|
('User'), |
||||
|
('commercial'), |
||||
|
('caisse'); |
||||
|
|
||||
|
-- Attribution de TOUTES les permissions à TOUS les menus pour le Super Admin |
||||
|
-- On utilise une sous-requête pour récupérer l'ID réel du rôle Super Admin |
||||
|
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`, `permission_id`) |
||||
|
SELECT r.id, m.id, p.id |
||||
|
FROM menu m |
||||
|
CROSS JOIN permissions p |
||||
|
CROSS JOIN roles r |
||||
|
WHERE r.designation = 'Super Admin'; |
||||
|
|
||||
|
-- Attribution de permissions basiques pour Admin |
||||
|
-- Accès en lecture/écriture à la plupart des menus sauf gestion des rôles |
||||
|
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`, `permission_id`) |
||||
|
SELECT r.id, m.id, p.id |
||||
|
FROM menu m |
||||
|
CROSS JOIN permissions p |
||||
|
CROSS JOIN roles r |
||||
|
WHERE r.designation = 'Admin' |
||||
|
AND m.name != 'Gérer les rôles' |
||||
|
AND p.name IN ('view', 'create', 'update', 'read'); |
||||
|
|
||||
|
-- Attribution de permissions basiques pour User |
||||
|
-- Accès principalement en lecture et quelques actions de base |
||||
|
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`, `permission_id`) |
||||
|
SELECT r.id, m.id, p.id |
||||
|
FROM menu m |
||||
|
CROSS JOIN permissions p |
||||
|
CROSS JOIN roles r |
||||
|
WHERE r.designation = 'User' |
||||
|
AND m.name IN ('Accueil', 'Nouvelle commande', 'Gérer les commandes', 'Gestion de stock', 'Historique') |
||||
|
AND p.name IN ('view', 'read', 'create'); |
||||
|
|
||||
|
-- Attribution de permissions pour Commercial |
||||
|
-- Accès aux commandes, clients, produits |
||||
|
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`, `permission_id`) |
||||
|
SELECT r.id, m.id, p.id |
||||
|
FROM menu m |
||||
|
CROSS JOIN permissions p |
||||
|
CROSS JOIN roles r |
||||
|
WHERE r.designation = 'commercial' |
||||
|
AND m.name IN ('Accueil', 'Nouvelle commande', 'Gérer les commandes', 'Bilan', 'Historique') |
||||
|
AND p.name IN ('view', 'create', 'update', 'read'); |
||||
|
|
||||
|
-- Attribution de permissions pour Caisse |
||||
|
-- Accès principalement aux commandes et stock |
||||
|
INSERT INTO `role_menu_permissions` (`role_id`, `menu_id`, `permission_id`) |
||||
|
SELECT r.id, m.id, p.id |
||||
|
FROM menu m |
||||
|
CROSS JOIN permissions p |
||||
|
CROSS JOIN roles r |
||||
|
WHERE r.designation = 'caisse' |
||||
|
AND m.name IN ('Accueil', 'Nouvelle commande', 'Gestion de stock') |
||||
|
AND p.name IN ('view', 'create', 'read'); |
||||
|
|
||||
|
-- Insertion du Super Admin par défaut |
||||
|
-- On utilise une sous-requête pour récupérer l'ID réel du rôle Super Admin |
||||
|
INSERT INTO `users` (`name`, `lastname`, `email`, `password`, `username`, `role_id`) |
||||
|
SELECT 'Super', 'Admin', 'superadmin@youmazgestion.com', 'admin123', 'superadmin', r.id |
||||
|
FROM roles r |
||||
|
WHERE r.designation = 'Super Admin'; |
||||
|
|
||||
|
-- ===================================================== |
||||
|
-- DONNÉES D'EXEMPLE (OPTIONNEL) |
||||
|
-- ===================================================== |
||||
|
|
||||
|
-- Insertion d'un point de vente d'exemple |
||||
|
INSERT INTO `points_de_vente` (`nom`) VALUES ('Magasin Principal'); |
||||
|
|
||||
|
-- Insertion d'un client d'exemple |
||||
|
INSERT INTO `clients` (`nom`, `prenom`, `email`, `telephone`, `adresse`, `dateCreation`, `actif`) VALUES |
||||
|
('Dupont', 'Jean', 'jean.dupont@email.com', '0123456789', '123 Rue de la Paix, Paris', NOW(), 1); |
||||
|
|
||||
|
-- ===================================================== |
||||
|
-- VÉRIFICATIONS |
||||
|
-- ===================================================== |
||||
|
|
||||
|
-- Afficher les rôles créés |
||||
|
SELECT 'RÔLES CRÉÉS:' as info; |
||||
|
SELECT * FROM roles; |
||||
|
|
||||
|
-- Afficher les permissions créées |
||||
|
SELECT 'PERMISSIONS CRÉÉES:' as info; |
||||
|
SELECT * FROM permissions; |
||||
|
|
||||
|
-- Afficher les menus créés |
||||
|
SELECT 'MENUS CRÉÉS:' as info; |
||||
|
SELECT * FROM menu; |
||||
|
|
||||
|
-- Afficher le Super Admin créé |
||||
|
SELECT 'SUPER ADMIN CRÉÉ:' as info; |
||||
|
SELECT u.username, u.email, r.designation as role |
||||
|
FROM users u |
||||
|
JOIN roles r ON u.role_id = r.id |
||||
|
WHERE r.designation = 'Super Admin'; |
||||
|
|
||||
|
-- Vérifier les permissions du Super Admin |
||||
|
SELECT 'PERMISSIONS SUPER ADMIN:' as info; |
||||
|
SELECT COUNT(*) as total_permissions_assignees |
||||
|
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; |
||||
|
|
||||
|
|
||||
|
|
||||
|
|
||||
|
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; |
||||
@ -1,680 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:io'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:path/path.dart'; |
|
||||
import 'package:path_provider/path_provider.dart'; |
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; |
|
||||
import '../Models/users.dart'; |
|
||||
import '../Models/role.dart'; |
|
||||
import '../Models/Permission.dart'; |
|
||||
|
|
||||
class AppDatabase { |
|
||||
static final AppDatabase instance = AppDatabase._init(); |
|
||||
late Database _database; |
|
||||
|
|
||||
AppDatabase._init() { |
|
||||
sqfliteFfiInit(); |
|
||||
} |
|
||||
|
|
||||
Future<Database> get database async { |
|
||||
if (_database.isOpen) return _database; |
|
||||
_database = await _initDB('app_database.db'); |
|
||||
return _database; |
|
||||
} |
|
||||
|
|
||||
Future<void> initDatabase() async { |
|
||||
_database = await _initDB('app_database.db'); |
|
||||
await _createDB(_database, 1); |
|
||||
await insertDefaultPermissions(); |
|
||||
await insertDefaultMenus(); |
|
||||
await insertDefaultRoles(); |
|
||||
await insertDefaultSuperAdmin(); |
|
||||
} |
|
||||
|
|
||||
Future<Database> _initDB(String filePath) async { |
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory(); |
|
||||
final path = join(documentsDirectory.path, filePath); |
|
||||
|
|
||||
bool dbExists = await File(path).exists(); |
|
||||
if (!dbExists) { |
|
||||
try { |
|
||||
ByteData data = await rootBundle.load('assets/database/$filePath'); |
|
||||
List<int> bytes = data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); |
|
||||
await File(path).writeAsBytes(bytes); |
|
||||
} catch (e) { |
|
||||
print('Pas de fichier DB dans assets, création d\'une nouvelle DB'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return await databaseFactoryFfi.openDatabase(path); |
|
||||
} |
|
||||
|
|
||||
Future<void> _createDB(Database db, int version) async { |
|
||||
final tables = await db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); |
|
||||
final tableNames = tables.map((row) => row['name'] as String).toList(); |
|
||||
|
|
||||
if (!tableNames.contains('roles')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE roles ( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
designation TEXT NOT NULL UNIQUE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'roles' créée."); |
|
||||
} |
|
||||
|
|
||||
if (!tableNames.contains('permissions')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE permissions ( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
name TEXT NOT NULL UNIQUE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'permissions' créée."); |
|
||||
} |
|
||||
|
|
||||
if (!tableNames.contains('menu')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE menu ( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
name TEXT NOT NULL UNIQUE, |
|
||||
route TEXT NOT NULL UNIQUE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'menu' créée."); |
|
||||
} |
|
||||
|
|
||||
if (!tableNames.contains('role_permissions')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE role_permissions ( |
|
||||
role_id INTEGER, |
|
||||
permission_id INTEGER, |
|
||||
PRIMARY KEY (role_id, permission_id), |
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, |
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'role_permissions' créée."); |
|
||||
} |
|
||||
|
|
||||
if (!tableNames.contains('menu_permissions')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE menu_permissions ( |
|
||||
menu_id INTEGER, |
|
||||
permission_id INTEGER, |
|
||||
PRIMARY KEY (menu_id, permission_id), |
|
||||
FOREIGN KEY (menu_id) REFERENCES menu(id) ON DELETE CASCADE, |
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'menu_permissions' créée."); |
|
||||
} |
|
||||
|
|
||||
if (!tableNames.contains('users')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE users ( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
name TEXT NOT NULL, |
|
||||
lastname TEXT NOT NULL, |
|
||||
email TEXT NOT NULL UNIQUE, |
|
||||
password TEXT NOT NULL, |
|
||||
username TEXT NOT NULL UNIQUE, |
|
||||
role_id INTEGER NOT NULL, |
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'users' créée."); |
|
||||
} |
|
||||
if (!tableNames.contains('role_menu_permissions')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE role_menu_permissions ( |
|
||||
role_id INTEGER, |
|
||||
menu_id INTEGER, |
|
||||
permission_id INTEGER, |
|
||||
PRIMARY KEY (role_id, menu_id, permission_id), |
|
||||
FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE, |
|
||||
FOREIGN KEY (menu_id) REFERENCES menu(id) ON DELETE CASCADE, |
|
||||
FOREIGN KEY (permission_id) REFERENCES permissions(id) ON DELETE CASCADE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'role_menu_permissions' créée."); |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
|
|
||||
Future<void> insertDefaultPermissions() async { |
|
||||
final db = await database; |
|
||||
final existing = await db.query('permissions'); |
|
||||
if (existing.isEmpty) { |
|
||||
await db.insert('permissions', {'name': 'view'}); |
|
||||
await db.insert('permissions', {'name': 'create'}); |
|
||||
await db.insert('permissions', {'name': 'update'}); |
|
||||
await db.insert('permissions', {'name': 'delete'}); |
|
||||
await db.insert('permissions', {'name': 'admin'}); |
|
||||
await db.insert('permissions', {'name': 'manage'}); // Nouvelle permission |
|
||||
await db.insert('permissions', {'name': 'read'}); // Nouvelle permission |
|
||||
print("Permissions par défaut insérées"); |
|
||||
} else { |
|
||||
// Vérifier et ajouter les nouvelles permissions si elles n'existent pas |
|
||||
final newPermissions = ['manage', 'read']; |
|
||||
for (var permission in newPermissions) { |
|
||||
final existingPermission = await db.query('permissions', where: 'name = ?', whereArgs: [permission]); |
|
||||
if (existingPermission.isEmpty) { |
|
||||
await db.insert('permissions', {'name': permission}); |
|
||||
print("Permission ajoutée: $permission"); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> insertDefaultMenus() async { |
|
||||
final db = await database; |
|
||||
final existingMenus = await db.query('menu'); |
|
||||
|
|
||||
if (existingMenus.isEmpty) { |
|
||||
// Menus existants |
|
||||
await db.insert('menu', {'name': 'Accueil', 'route': '/accueil'}); |
|
||||
await db.insert('menu', {'name': 'Ajouter un utilisateur', 'route': '/ajouter-utilisateur'}); |
|
||||
await db.insert('menu', {'name': 'Modifier/Supprimer un utilisateur', 'route': '/modifier-utilisateur'}); |
|
||||
await db.insert('menu', {'name': 'Ajouter un produit', 'route': '/ajouter-produit'}); |
|
||||
await db.insert('menu', {'name': 'Modifier/Supprimer un produit', 'route': '/modifier-produit'}); |
|
||||
await db.insert('menu', {'name': 'Bilan', 'route': '/bilan'}); |
|
||||
await db.insert('menu', {'name': 'Gérer les rôles', 'route': '/gerer-roles'}); |
|
||||
await db.insert('menu', {'name': 'Gestion de stock', 'route': '/gestion-stock'}); |
|
||||
await db.insert('menu', {'name': 'Historique', 'route': '/historique'}); |
|
||||
await db.insert('menu', {'name': 'Déconnexion', 'route': '/deconnexion'}); |
|
||||
|
|
||||
// Nouveaux menus ajoutés |
|
||||
await db.insert('menu', {'name': 'Nouvelle commande', 'route': '/nouvelle-commande'}); |
|
||||
await db.insert('menu', {'name': 'Gérer les commandes', 'route': '/gerer-commandes'}); |
|
||||
|
|
||||
print("Menus par défaut insérés"); |
|
||||
} else { |
|
||||
// Si des menus existent déjà, vérifier et ajouter les nouveaux menus manquants |
|
||||
await _addMissingMenus(db); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _addMissingMenus(Database db) async { |
|
||||
final menusToAdd = [ |
|
||||
{'name': 'Nouvelle commande', 'route': '/nouvelle-commande'}, |
|
||||
{'name': 'Gérer les commandes', 'route': '/gerer-commandes'}, |
|
||||
]; |
|
||||
|
|
||||
for (var menu in menusToAdd) { |
|
||||
final existing = await db.query( |
|
||||
'menu', |
|
||||
where: 'route = ?', |
|
||||
whereArgs: [menu['route']], |
|
||||
); |
|
||||
|
|
||||
if (existing.isEmpty) { |
|
||||
await db.insert('menu', menu); |
|
||||
print("Menu ajouté: ${menu['name']}"); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> insertDefaultRoles() async { |
|
||||
final db = await database; |
|
||||
final existingRoles = await db.query('roles'); |
|
||||
|
|
||||
if (existingRoles.isEmpty) { |
|
||||
int superAdminRoleId = await db.insert('roles', {'designation': 'Super Admin'}); |
|
||||
int adminRoleId = await db.insert('roles', {'designation': 'Admin'}); |
|
||||
int userRoleId = await db.insert('roles', {'designation': 'User'}); |
|
||||
|
|
||||
final permissions = await db.query('permissions'); |
|
||||
final menus = await db.query('menu'); |
|
||||
|
|
||||
// Assigner toutes les permissions à tous les menus pour le Super Admin |
|
||||
for (var menu in menus) { |
|
||||
for (var permission in permissions) { |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': superAdminRoleId, |
|
||||
'menu_id': menu['id'], |
|
||||
'permission_id': permission['id'], |
|
||||
}, |
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Assigner quelques permissions à l'Admin et à l'User pour les nouveaux menus |
|
||||
await _assignBasicPermissionsToRoles(db, adminRoleId, userRoleId); |
|
||||
|
|
||||
print("Rôles par défaut créés et permissions assignées"); |
|
||||
} else { |
|
||||
// Si les rôles existent déjà, vérifier et ajouter les permissions manquantes |
|
||||
await _updateExistingRolePermissions(db); |
|
||||
} |
|
||||
} |
|
||||
// Nouvelle méthode pour assigner les permissions de base aux nouveaux menus |
|
||||
Future<void> _assignBasicPermissionsToRoles(Database db, int adminRoleId, int userRoleId) async { |
|
||||
final viewPermission = await db.query('permissions', where: 'name = ?', whereArgs: ['view']); |
|
||||
final createPermission = await db.query('permissions', where: 'name = ?', whereArgs: ['create']); |
|
||||
final updatePermission = await db.query('permissions', where: 'name = ?', whereArgs: ['update']); |
|
||||
final managePermission = await db.query('permissions', where: 'name = ?', whereArgs: ['manage']); |
|
||||
|
|
||||
// Récupérer les IDs des nouveaux menus |
|
||||
final nouvelleCommandeMenu = await db.query('menu', where: 'route = ?', whereArgs: ['/nouvelle-commande']); |
|
||||
final gererCommandesMenu = await db.query('menu', where: 'route = ?', whereArgs: ['/gerer-commandes']); |
|
||||
|
|
||||
if (nouvelleCommandeMenu.isNotEmpty && createPermission.isNotEmpty) { |
|
||||
// Admin peut créer de nouvelles commandes |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': adminRoleId, |
|
||||
'menu_id': nouvelleCommandeMenu.first['id'], |
|
||||
'permission_id': createPermission.first['id'], |
|
||||
}, |
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore |
|
||||
); |
|
||||
|
|
||||
// User peut aussi créer de nouvelles commandes |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': userRoleId, |
|
||||
'menu_id': nouvelleCommandeMenu.first['id'], |
|
||||
'permission_id': createPermission.first['id'], |
|
||||
}, |
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (gererCommandesMenu.isNotEmpty && managePermission.isNotEmpty) { |
|
||||
// Admin peut gérer les commandes |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': adminRoleId, |
|
||||
'menu_id': gererCommandesMenu.first['id'], |
|
||||
'permission_id': managePermission.first['id'], |
|
||||
}, |
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
if (gererCommandesMenu.isNotEmpty && viewPermission.isNotEmpty) { |
|
||||
// User peut voir les commandes |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': userRoleId, |
|
||||
'menu_id': gererCommandesMenu.first['id'], |
|
||||
'permission_id': viewPermission.first['id'], |
|
||||
} |
|
||||
, conflictAlgorithm: ConflictAlgorithm.ignore |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
Future<void> _updateExistingRolePermissions(Database db) async { |
|
||||
final superAdminRole = await db.query('roles', where: 'designation = ?', whereArgs: ['Super Admin']); |
|
||||
if (superAdminRole.isNotEmpty) { |
|
||||
final superAdminRoleId = superAdminRole.first['id'] as int; |
|
||||
final permissions = await db.query('permissions'); |
|
||||
final menus = await db.query('menu'); |
|
||||
|
|
||||
// Vérifier et ajouter les permissions manquantes pour le Super Admin sur tous les menus |
|
||||
for (var menu in menus) { |
|
||||
for (var permission in permissions) { |
|
||||
final existingPermission = await db.query( |
|
||||
'role_menu_permissions', |
|
||||
where: 'role_id = ? AND menu_id = ? AND permission_id = ?', |
|
||||
whereArgs: [superAdminRoleId, menu['id'], permission['id']], |
|
||||
); |
|
||||
if (existingPermission.isEmpty) { |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': superAdminRoleId, |
|
||||
'menu_id': menu['id'], |
|
||||
'permission_id': permission['id'], |
|
||||
}, |
|
||||
conflictAlgorithm: ConflictAlgorithm.ignore |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
// Assigner les permissions de base aux autres rôles pour les nouveaux menus |
|
||||
final adminRole = await db.query('roles', where: 'designation = ?', whereArgs: ['Admin']); |
|
||||
final userRole = await db.query('roles', where: 'designation = ?', whereArgs: ['User']); |
|
||||
|
|
||||
if (adminRole.isNotEmpty && userRole.isNotEmpty) { |
|
||||
await _assignBasicPermissionsToRoles(db, adminRole.first['id'] as int, userRole.first['id'] as int); |
|
||||
} |
|
||||
|
|
||||
print("Permissions mises à jour pour tous les rôles"); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
|
|
||||
Future<void> insertDefaultSuperAdmin() async { |
|
||||
final db = await database; |
|
||||
|
|
||||
final existingSuperAdmin = await db.rawQuery(''' |
|
||||
SELECT u.* FROM users u |
|
||||
INNER JOIN roles r ON u.role_id = r.id |
|
||||
WHERE r.designation = 'Super Admin' |
|
||||
'''); |
|
||||
|
|
||||
if (existingSuperAdmin.isEmpty) { |
|
||||
final superAdminRole = await db.query('roles', |
|
||||
where: 'designation = ?', |
|
||||
whereArgs: ['Super Admin'] |
|
||||
); |
|
||||
|
|
||||
if (superAdminRole.isNotEmpty) { |
|
||||
final superAdminRoleId = superAdminRole.first['id'] as int; |
|
||||
|
|
||||
await db.insert('users', { |
|
||||
'name': 'Super', |
|
||||
'lastname': 'Admin', |
|
||||
'email': 'superadmin@youmazgestion.com', |
|
||||
'password': 'admin123', |
|
||||
'username': 'superadmin', |
|
||||
'role_id': superAdminRoleId, |
|
||||
}); |
|
||||
|
|
||||
print("Super Admin créé avec succès !"); |
|
||||
print("Username: superadmin"); |
|
||||
print("Password: admin123"); |
|
||||
print("ATTENTION: Changez ce mot de passe après la première connexion !"); |
|
||||
} |
|
||||
} else { |
|
||||
print("Super Admin existe déjà"); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<int> createUser(Users user) async { |
|
||||
final db = await database; |
|
||||
return await db.insert('users', user.toMap()); |
|
||||
} |
|
||||
|
|
||||
Future<int> deleteUser(int id) async { |
|
||||
final db = await database; |
|
||||
return await db.delete('users', where: 'id = ?', whereArgs: [id]); |
|
||||
} |
|
||||
|
|
||||
Future<int> updateUser(Users user) async { |
|
||||
final db = await database; |
|
||||
return await db.update('users', user.toMap(), where: 'id = ?', whereArgs: [user.id]); |
|
||||
} |
|
||||
|
|
||||
Future<int> getUserCount() async { |
|
||||
final db = await database; |
|
||||
List<Map<String, dynamic>> result = await db.rawQuery('SELECT COUNT(*) as count FROM users'); |
|
||||
return result.first['count'] as int; |
|
||||
} |
|
||||
|
|
||||
Future<bool> verifyUser(String username, String password) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT users.id |
|
||||
FROM users |
|
||||
WHERE users.username = ? AND users.password = ? |
|
||||
''', [username, password]); |
|
||||
return result.isNotEmpty; |
|
||||
} |
|
||||
|
|
||||
Future<Users> getUser(String username) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT users.*, roles.designation as role_name |
|
||||
FROM users |
|
||||
INNER JOIN roles ON users.role_id = roles.id |
|
||||
WHERE users.username = ? |
|
||||
''', [username]); |
|
||||
|
|
||||
if (result.isNotEmpty) { |
|
||||
return Users.fromMap(result.first); |
|
||||
} else { |
|
||||
throw Exception('User not found'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<Map<String, dynamic>?> getUserCredentials(String username, String password) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT users.username, users.id, roles.designation as role_name, roles.id as role_id |
|
||||
FROM users |
|
||||
INNER JOIN roles ON users.role_id = roles.id |
|
||||
WHERE username = ? AND password = ? |
|
||||
''', [username, password]); |
|
||||
|
|
||||
if (result.isNotEmpty) { |
|
||||
return { |
|
||||
'id': result.first['id'], |
|
||||
'username': result.first['username'] as String, |
|
||||
'role': result.first['role_name'] as String, |
|
||||
'role_id': result.first['role_id'], |
|
||||
}; |
|
||||
} else { |
|
||||
return null; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<List<Users>> getAllUsers() async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT users.*, roles.designation as role_name |
|
||||
FROM users |
|
||||
INNER JOIN roles ON users.role_id = roles.id |
|
||||
ORDER BY users.id ASC |
|
||||
'''); |
|
||||
return result.map((json) => Users.fromMap(json)).toList(); |
|
||||
} |
|
||||
|
|
||||
Future<int> createRole(Role role) async { |
|
||||
final db = await database; |
|
||||
return await db.insert('roles', role.toMap()); |
|
||||
} |
|
||||
|
|
||||
Future<List<Role>> getRoles() async { |
|
||||
final db = await database; |
|
||||
final maps = await db.query('roles', orderBy: 'designation ASC'); |
|
||||
return List.generate(maps.length, (i) => Role.fromMap(maps[i])); |
|
||||
} |
|
||||
|
|
||||
Future<int> updateRole(Role role) async { |
|
||||
final db = await database; |
|
||||
return await db.update( |
|
||||
'roles', |
|
||||
role.toMap(), |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [role.id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<int> deleteRole(int? id) async { |
|
||||
final db = await database; |
|
||||
return await db.delete( |
|
||||
'roles', |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<List<Permission>> getAllPermissions() async { |
|
||||
final db = await database; |
|
||||
final result = await db.query('permissions', orderBy: 'name ASC'); |
|
||||
return result.map((e) => Permission.fromMap(e)).toList(); |
|
||||
} |
|
||||
|
|
||||
Future<List<Permission>> getPermissionsForRole(int roleId) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT p.id, p.name |
|
||||
FROM permissions p |
|
||||
JOIN role_permissions rp ON p.id = rp.permission_id |
|
||||
WHERE rp.role_id = ? |
|
||||
ORDER BY p.name ASC |
|
||||
''', [roleId]); |
|
||||
|
|
||||
return result.map((map) => Permission.fromMap(map)).toList(); |
|
||||
} |
|
||||
|
|
||||
Future<List<Permission>> getPermissionsForUser(String username) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT DISTINCT p.id, p.name |
|
||||
FROM permissions p |
|
||||
JOIN role_permissions rp ON p.id = rp.permission_id |
|
||||
JOIN roles r ON rp.role_id = r.id |
|
||||
JOIN users u ON u.role_id = r.id |
|
||||
WHERE u.username = ? |
|
||||
ORDER BY p.name ASC |
|
||||
''', [username]); |
|
||||
|
|
||||
return result.map((map) => Permission.fromMap(map)).toList(); |
|
||||
} |
|
||||
|
|
||||
Future<void> assignPermission(int roleId, int permissionId) async { |
|
||||
final db = await database; |
|
||||
await db.insert('role_permissions', { |
|
||||
'role_id': roleId, |
|
||||
'permission_id': permissionId, |
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore); |
|
||||
} |
|
||||
|
|
||||
Future<void> removePermission(int roleId, int permissionId) async { |
|
||||
final db = await database; |
|
||||
await db.delete( |
|
||||
'role_permissions', |
|
||||
where: 'role_id = ? AND permission_id = ?', |
|
||||
whereArgs: [roleId, permissionId], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<void> assignMenuPermission(int menuId, int permissionId) async { |
|
||||
final db = await database; |
|
||||
await db.insert('menu_permissions', { |
|
||||
'menu_id': menuId, |
|
||||
'permission_id': permissionId, |
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore); |
|
||||
} |
|
||||
|
|
||||
Future<void> removeMenuPermission(int menuId, int permissionId) async { |
|
||||
final db = await database; |
|
||||
await db.delete( |
|
||||
'menu_permissions', |
|
||||
where: 'menu_id = ? AND permission_id = ?', |
|
||||
whereArgs: [menuId, permissionId], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<bool> isSuperAdmin(String username) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT COUNT(*) as count |
|
||||
FROM users u |
|
||||
INNER JOIN roles r ON u.role_id = r.id |
|
||||
WHERE u.username = ? AND r.designation = 'Super Admin' |
|
||||
''', [username]); |
|
||||
|
|
||||
return (result.first['count'] as int) > 0; |
|
||||
} |
|
||||
|
|
||||
Future<void> changePassword(String username, String oldPassword, String newPassword) async { |
|
||||
final db = await database; |
|
||||
|
|
||||
final isValidOldPassword = await verifyUser(username, oldPassword); |
|
||||
if (!isValidOldPassword) { |
|
||||
throw Exception('Ancien mot de passe incorrect'); |
|
||||
} |
|
||||
|
|
||||
await db.update( |
|
||||
'users', |
|
||||
{'password': newPassword}, |
|
||||
where: 'username = ?', |
|
||||
whereArgs: [username], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<bool> hasPermission(String username, String permissionName, String menuRoute) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT COUNT(*) as count |
|
||||
FROM permissions p |
|
||||
JOIN role_menu_permissions rmp ON p.id = rmp.permission_id |
|
||||
JOIN roles r ON rmp.role_id = r.id |
|
||||
JOIN users u ON u.role_id = r.id |
|
||||
JOIN menu m ON m.route = ? |
|
||||
WHERE u.username = ? AND p.name = ? AND rmp.menu_id = m.id |
|
||||
''', [menuRoute, username, permissionName]); |
|
||||
|
|
||||
return (result.first['count'] as int) > 0; |
|
||||
} |
|
||||
|
|
||||
|
|
||||
Future<void> close() async { |
|
||||
if (_database.isOpen) { |
|
||||
await _database.close(); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> printDatabaseInfo() async { |
|
||||
final db = await database; |
|
||||
|
|
||||
print("=== INFORMATIONS DE LA BASE DE DONNÉES ==="); |
|
||||
|
|
||||
final userCount = await getUserCount(); |
|
||||
print("Nombre d'utilisateurs: $userCount"); |
|
||||
|
|
||||
final users = await getAllUsers(); |
|
||||
print("Utilisateurs:"); |
|
||||
for (var user in users) { |
|
||||
print(" - ${user.username} (${user.name} ) - Email: ${user.email}"); |
|
||||
} |
|
||||
|
|
||||
final roles = await getRoles(); |
|
||||
print("Rôles:"); |
|
||||
for (var role in roles) { |
|
||||
print(" - ${role.designation} (ID: ${role.id})"); |
|
||||
} |
|
||||
|
|
||||
final permissions = await getAllPermissions(); |
|
||||
print("Permissions:"); |
|
||||
for (var permission in permissions) { |
|
||||
print(" - ${permission.name} (ID: ${permission.id})"); |
|
||||
} |
|
||||
|
|
||||
print("========================================="); |
|
||||
} |
|
||||
|
|
||||
Future<List<Permission>> getPermissionsForRoleAndMenu(int roleId, int menuId) async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery(''' |
|
||||
SELECT p.id, p.name |
|
||||
FROM permissions p |
|
||||
JOIN role_menu_permissions rmp ON p.id = rmp.permission_id |
|
||||
WHERE rmp.role_id = ? AND rmp.menu_id = ? |
|
||||
ORDER BY p.name ASC |
|
||||
''', [roleId, menuId]); |
|
||||
|
|
||||
return result.map((map) => Permission.fromMap(map)).toList(); |
|
||||
} |
|
||||
// Ajoutez cette méthode temporaire pour supprimer la DB corrompue |
|
||||
Future<void> deleteDatabaseFile() async { |
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory(); |
|
||||
final path = join(documentsDirectory.path, 'app_database.db'); |
|
||||
final file = File(path); |
|
||||
if (await file.exists()) { |
|
||||
await file.delete(); |
|
||||
print("Base de données utilisateur supprimée"); |
|
||||
} |
|
||||
} |
|
||||
Future<void> assignRoleMenuPermission(int roleId, int menuId, int permissionId) async { |
|
||||
final db = await database; |
|
||||
await db.insert('role_menu_permissions', { |
|
||||
'role_id': roleId, |
|
||||
'menu_id': menuId, |
|
||||
'permission_id': permissionId, |
|
||||
}, conflictAlgorithm: ConflictAlgorithm.ignore); |
|
||||
} |
|
||||
|
|
||||
|
|
||||
|
|
||||
Future<void> removeRoleMenuPermission(int roleId, int menuId, int permissionId) async { |
|
||||
final db = await database; |
|
||||
await db.delete( |
|
||||
'role_menu_permissions', |
|
||||
where: 'role_id = ? AND menu_id = ? AND permission_id = ?', |
|
||||
whereArgs: [roleId, menuId, permissionId], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
} |
|
||||
@ -1,559 +0,0 @@ |
|||||
import 'dart:async'; |
|
||||
import 'dart:io'; |
|
||||
import 'package:flutter/services.dart'; |
|
||||
import 'package:path/path.dart'; |
|
||||
import 'package:path_provider/path_provider.dart'; |
|
||||
import 'package:sqflite_common_ffi/sqflite_ffi.dart'; |
|
||||
import '../Models/produit.dart'; |
|
||||
import '../Models/client.dart'; |
|
||||
|
|
||||
|
|
||||
class ProductDatabase { |
|
||||
static final ProductDatabase instance = ProductDatabase._init(); |
|
||||
late Database _database; |
|
||||
|
|
||||
ProductDatabase._init() { |
|
||||
sqfliteFfiInit(); |
|
||||
} |
|
||||
|
|
||||
ProductDatabase(); |
|
||||
|
|
||||
Future<Database> get database async { |
|
||||
if (_database.isOpen) return _database; |
|
||||
_database = await _initDB('products2.db'); |
|
||||
return _database; |
|
||||
} |
|
||||
|
|
||||
Future<void> initDatabase() async { |
|
||||
_database = await _initDB('products2.db'); |
|
||||
await _createDB(_database, 1); |
|
||||
await _insertDefaultClients(); |
|
||||
await _insertDefaultCommandes(); |
|
||||
} |
|
||||
|
|
||||
Future<Database> _initDB(String filePath) async { |
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory(); |
|
||||
final path = join(documentsDirectory.path, filePath); |
|
||||
|
|
||||
bool dbExists = await File(path).exists(); |
|
||||
if (!dbExists) { |
|
||||
try { |
|
||||
ByteData data = await rootBundle.load('assets/database/$filePath'); |
|
||||
List<int> bytes = |
|
||||
data.buffer.asUint8List(data.offsetInBytes, data.lengthInBytes); |
|
||||
await File(path).writeAsBytes(bytes); |
|
||||
} catch (e) { |
|
||||
print('Pas de fichier DB dans assets, création nouvelle DB'); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
return await databaseFactoryFfi.openDatabase(path); |
|
||||
} |
|
||||
|
|
||||
Future<void> _createDB(Database db, int version) async { |
|
||||
final tables = await db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'"); |
|
||||
final tableNames = tables.map((row) => row['name'] as String).toList(); |
|
||||
|
|
||||
// Table products (existante avec améliorations) |
|
||||
if (!tableNames.contains('products')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE products( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
name TEXT NOT NULL, |
|
||||
price REAL NOT NULL, |
|
||||
image TEXT, |
|
||||
category TEXT NOT NULL, |
|
||||
stock INTEGER NOT NULL DEFAULT 0, |
|
||||
description TEXT, |
|
||||
qrCode TEXT, |
|
||||
reference TEXT UNIQUE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'products' créée."); |
|
||||
} else { |
|
||||
// Vérifier et ajouter les colonnes manquantes |
|
||||
await _updateProductsTable(db); |
|
||||
} |
|
||||
|
|
||||
// Table clients |
|
||||
if (!tableNames.contains('clients')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE clients( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
nom TEXT NOT NULL, |
|
||||
prenom TEXT NOT NULL, |
|
||||
email TEXT NOT NULL UNIQUE, |
|
||||
telephone TEXT NOT NULL, |
|
||||
adresse TEXT, |
|
||||
dateCreation TEXT NOT NULL, |
|
||||
actif INTEGER NOT NULL DEFAULT 1 |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'clients' créée."); |
|
||||
} |
|
||||
|
|
||||
// Table commandes |
|
||||
if (!tableNames.contains('commandes')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE commandes( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
clientId INTEGER NOT NULL, |
|
||||
dateCommande TEXT NOT NULL, |
|
||||
statut INTEGER NOT NULL DEFAULT 0, |
|
||||
montantTotal REAL NOT NULL, |
|
||||
notes TEXT, |
|
||||
dateLivraison TEXT, |
|
||||
FOREIGN KEY (clientId) REFERENCES clients(id) ON DELETE CASCADE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'commandes' créée."); |
|
||||
} |
|
||||
|
|
||||
// Table détails commandes |
|
||||
if (!tableNames.contains('details_commandes')) { |
|
||||
await db.execute(''' |
|
||||
CREATE TABLE details_commandes( |
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
||||
commandeId INTEGER NOT NULL, |
|
||||
produitId INTEGER NOT NULL, |
|
||||
quantite INTEGER NOT NULL, |
|
||||
prixUnitaire REAL NOT NULL, |
|
||||
sousTotal REAL NOT NULL, |
|
||||
FOREIGN KEY (commandeId) REFERENCES commandes(id) ON DELETE CASCADE, |
|
||||
FOREIGN KEY (produitId) REFERENCES products(id) ON DELETE CASCADE |
|
||||
) |
|
||||
'''); |
|
||||
print("Table 'details_commandes' créée."); |
|
||||
} |
|
||||
|
|
||||
// Créer les index pour optimiser les performances |
|
||||
await _createIndexes(db); |
|
||||
} |
|
||||
|
|
||||
Future<void> _updateProductsTable(Database db) async { |
|
||||
final columns = await db.rawQuery('PRAGMA table_info(products)'); |
|
||||
final columnNames = columns.map((e) => e['name'] as String).toList(); |
|
||||
|
|
||||
if (!columnNames.contains('description')) { |
|
||||
await db.execute("ALTER TABLE products ADD COLUMN description TEXT"); |
|
||||
print("Colonne 'description' ajoutée."); |
|
||||
} |
|
||||
if (!columnNames.contains('qrCode')) { |
|
||||
await db.execute("ALTER TABLE products ADD COLUMN qrCode TEXT"); |
|
||||
print("Colonne 'qrCode' ajoutée."); |
|
||||
} |
|
||||
if (!columnNames.contains('reference')) { |
|
||||
await db.execute("ALTER TABLE products ADD COLUMN reference TEXT"); |
|
||||
print("Colonne 'reference' ajoutée."); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _createIndexes(Database db) async { |
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_products_category ON products(category)'); |
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_products_reference ON products(reference)'); |
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_commandes_client ON commandes(clientId)'); |
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_commandes_date ON commandes(dateCommande)'); |
|
||||
await db.execute('CREATE INDEX IF NOT EXISTS idx_details_commande ON details_commandes(commandeId)'); |
|
||||
print("Index créés pour optimiser les performances."); |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// MÉTHODES PRODUCTS (existantes) |
|
||||
// ========================= |
|
||||
Future<int> createProduct(Product product) async { |
|
||||
final db = await database; |
|
||||
return await db.insert('products', product.toMap()); |
|
||||
} |
|
||||
|
|
||||
Future<List<Product>> getProducts() async { |
|
||||
final db = await database; |
|
||||
final maps = await db.query('products', orderBy: 'name ASC'); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return Product.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
Future<int> updateProduct(Product product) async { |
|
||||
final db = await database; |
|
||||
return await db.update( |
|
||||
'products', |
|
||||
product.toMap(), |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [product.id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<int> deleteProduct(int? id) async { |
|
||||
final db = await database; |
|
||||
return await db.delete( |
|
||||
'products', |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<List<String>> getCategories() async { |
|
||||
final db = await database; |
|
||||
final result = await db.rawQuery('SELECT DISTINCT category FROM products ORDER BY category'); |
|
||||
return List.generate( |
|
||||
result.length, (index) => result[index]['category'] as String); |
|
||||
} |
|
||||
|
|
||||
Future<List<Product>> getProductsByCategory(String category) async { |
|
||||
final db = await database; |
|
||||
final maps = await db |
|
||||
.query('products', where: 'category = ?', whereArgs: [category], orderBy: 'name ASC'); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return Product.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
Future<int> updateStock(int id, int stock) async { |
|
||||
final db = await database; |
|
||||
return await db |
|
||||
.rawUpdate('UPDATE products SET stock = ? WHERE id = ?', [stock, id]); |
|
||||
} |
|
||||
|
|
||||
Future<Product?> getProductByReference(String reference) async { |
|
||||
final db = await database; |
|
||||
final maps = await db.query( |
|
||||
'products', |
|
||||
where: 'reference = ?', |
|
||||
whereArgs: [reference], |
|
||||
); |
|
||||
|
|
||||
if (maps.isNotEmpty) { |
|
||||
return Product.fromMap(maps.first); |
|
||||
} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// MÉTHODES CLIENTS |
|
||||
// ========================= |
|
||||
Future<int> createClient(Client client) async { |
|
||||
final db = await database; |
|
||||
return await db.insert('clients', client.toMap()); |
|
||||
} |
|
||||
|
|
||||
Future<List<Client>> getClients() async { |
|
||||
final db = await database; |
|
||||
final maps = await db.query('clients', where: 'actif = 1', orderBy: 'nom ASC, prenom ASC'); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return Client.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
Future<Client?> getClientById(int id) async { |
|
||||
final db = await database; |
|
||||
final maps = await db.query('clients', where: 'id = ?', whereArgs: [id]); |
|
||||
if (maps.isNotEmpty) { |
|
||||
return Client.fromMap(maps.first); |
|
||||
} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
Future<int> updateClient(Client client) async { |
|
||||
final db = await database; |
|
||||
return await db.update( |
|
||||
'clients', |
|
||||
client.toMap(), |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [client.id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<int> deleteClient(int id) async { |
|
||||
final db = await database; |
|
||||
// Soft delete |
|
||||
return await db.update( |
|
||||
'clients', |
|
||||
{'actif': 0}, |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<List<Client>> searchClients(String query) async { |
|
||||
final db = await database; |
|
||||
final maps = await db.query( |
|
||||
'clients', |
|
||||
where: 'actif = 1 AND (nom LIKE ? OR prenom LIKE ? OR email LIKE ?)', |
|
||||
whereArgs: ['%$query%', '%$query%', '%$query%'], |
|
||||
orderBy: 'nom ASC, prenom ASC', |
|
||||
); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return Client.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// MÉTHODES COMMANDES |
|
||||
// ========================= |
|
||||
Future<int> createCommande(Commande commande) async { |
|
||||
final db = await database; |
|
||||
return await db.insert('commandes', commande.toMap()); |
|
||||
} |
|
||||
|
|
||||
Future<List<Commande>> getCommandes() async { |
|
||||
final db = await database; |
|
||||
final maps = await db.rawQuery(''' |
|
||||
SELECT c.*, cl.nom as clientNom, cl.prenom as clientPrenom, cl.email as clientEmail |
|
||||
FROM commandes c |
|
||||
LEFT JOIN clients cl ON c.clientId = cl.id |
|
||||
ORDER BY c.dateCommande DESC |
|
||||
'''); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return Commande.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
Future<List<Commande>> getCommandesByClient(int clientId) async { |
|
||||
final db = await database; |
|
||||
final maps = await db.rawQuery(''' |
|
||||
SELECT c.*, cl.nom as clientNom, cl.prenom as clientPrenom, cl.email as clientEmail |
|
||||
FROM commandes c |
|
||||
LEFT JOIN clients cl ON c.clientId = cl.id |
|
||||
WHERE c.clientId = ? |
|
||||
ORDER BY c.dateCommande DESC |
|
||||
''', [clientId]); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return Commande.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
Future<Commande?> getCommandeById(int id) async { |
|
||||
final db = await database; |
|
||||
final maps = await db.rawQuery(''' |
|
||||
SELECT c.*, cl.nom as clientNom, cl.prenom as clientPrenom, cl.email as clientEmail |
|
||||
FROM commandes c |
|
||||
LEFT JOIN clients cl ON c.clientId = cl.id |
|
||||
WHERE c.id = ? |
|
||||
''', [id]); |
|
||||
if (maps.isNotEmpty) { |
|
||||
return Commande.fromMap(maps.first); |
|
||||
} |
|
||||
return null; |
|
||||
} |
|
||||
|
|
||||
Future<int> updateCommande(Commande commande) async { |
|
||||
final db = await database; |
|
||||
return await db.update( |
|
||||
'commandes', |
|
||||
commande.toMap(), |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [commande.id], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
Future<int> updateStatutCommande(int commandeId, StatutCommande statut) async { |
|
||||
final db = await database; |
|
||||
return await db.update( |
|
||||
'commandes', |
|
||||
{'statut': statut.index}, |
|
||||
where: 'id = ?', |
|
||||
whereArgs: [commandeId], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// MÉTHODES DÉTAILS COMMANDES |
|
||||
// ========================= |
|
||||
Future<int> createDetailCommande(DetailCommande detail) async { |
|
||||
final db = await database; |
|
||||
return await db.insert('details_commandes', detail.toMap()); |
|
||||
} |
|
||||
|
|
||||
Future<List<DetailCommande>> getDetailsCommande(int commandeId) async { |
|
||||
final db = await database; |
|
||||
final maps = await db.rawQuery(''' |
|
||||
SELECT dc.*, p.name as produitNom, p.image as produitImage, p.reference as produitReference |
|
||||
FROM details_commandes dc |
|
||||
LEFT JOIN products p ON dc.produitId = p.id |
|
||||
WHERE dc.commandeId = ? |
|
||||
ORDER BY dc.id |
|
||||
''', [commandeId]); |
|
||||
return List.generate(maps.length, (i) { |
|
||||
return DetailCommande.fromMap(maps[i]); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// MÉTHODES TRANSACTION COMPLÈTE |
|
||||
// ========================= |
|
||||
Future<int> createCommandeComplete(Client client, Commande commande, List<DetailCommande> details) async { |
|
||||
final db = await database; |
|
||||
|
|
||||
return await db.transaction((txn) async { |
|
||||
// Créer le client |
|
||||
final clientId = await txn.insert('clients', client.toMap()); |
|
||||
|
|
||||
// Créer la commande |
|
||||
final commandeMap = commande.toMap(); |
|
||||
commandeMap['clientId'] = clientId; |
|
||||
final commandeId = await txn.insert('commandes', commandeMap); |
|
||||
|
|
||||
// Créer les détails et mettre à jour le stock |
|
||||
for (var detail in details) { |
|
||||
final detailMap = detail.toMap(); |
|
||||
detailMap['commandeId'] = commandeId; // Ajoute l'ID de la commande |
|
||||
await txn.insert('details_commandes', detailMap); |
|
||||
|
|
||||
// Mettre à jour le stock du produit |
|
||||
await txn.rawUpdate( |
|
||||
'UPDATE products SET stock = stock - ? WHERE id = ?', |
|
||||
[detail.quantite, detail.produitId], |
|
||||
); |
|
||||
} |
|
||||
|
|
||||
return commandeId; |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// STATISTIQUES |
|
||||
// ========================= |
|
||||
Future<Map<String, dynamic>> getStatistiques() async { |
|
||||
final db = await database; |
|
||||
|
|
||||
final totalClients = await db.rawQuery('SELECT COUNT(*) as count FROM clients WHERE actif = 1'); |
|
||||
final totalCommandes = await db.rawQuery('SELECT COUNT(*) as count FROM commandes'); |
|
||||
final totalProduits = await db.rawQuery('SELECT COUNT(*) as count FROM products'); |
|
||||
final chiffreAffaires = await db.rawQuery('SELECT SUM(montantTotal) as total FROM commandes WHERE statut != 5'); // 5 = annulée |
|
||||
|
|
||||
return { |
|
||||
'totalClients': totalClients.first['count'], |
|
||||
'totalCommandes': totalCommandes.first['count'], |
|
||||
'totalProduits': totalProduits.first['count'], |
|
||||
'chiffreAffaires': chiffreAffaires.first['total'] ?? 0.0, |
|
||||
}; |
|
||||
} |
|
||||
|
|
||||
// ========================= |
|
||||
// DONNÉES PAR DÉFAUT |
|
||||
// ========================= |
|
||||
Future<void> _insertDefaultClients() async { |
|
||||
final db = await database; |
|
||||
final existingClients = await db.query('clients'); |
|
||||
|
|
||||
if (existingClients.isEmpty) { |
|
||||
final defaultClients = [ |
|
||||
Client( |
|
||||
nom: 'Dupont', |
|
||||
prenom: 'Jean', |
|
||||
email: 'jean.dupont@email.com', |
|
||||
telephone: '0123456789', |
|
||||
adresse: '123 Rue de la Paix, Paris', |
|
||||
dateCreation: DateTime.now(), |
|
||||
), |
|
||||
Client( |
|
||||
nom: 'Martin', |
|
||||
prenom: 'Marie', |
|
||||
email: 'marie.martin@email.com', |
|
||||
telephone: '0987654321', |
|
||||
adresse: '456 Avenue des Champs, Lyon', |
|
||||
dateCreation: DateTime.now(), |
|
||||
), |
|
||||
Client( |
|
||||
nom: 'Bernard', |
|
||||
prenom: 'Pierre', |
|
||||
email: 'pierre.bernard@email.com', |
|
||||
telephone: '0456789123', |
|
||||
adresse: '789 Boulevard Saint-Michel, Marseille', |
|
||||
dateCreation: DateTime.now(), |
|
||||
), |
|
||||
]; |
|
||||
|
|
||||
for (var client in defaultClients) { |
|
||||
await db.insert('clients', client.toMap()); |
|
||||
} |
|
||||
print("Clients par défaut insérés"); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> _insertDefaultCommandes() async { |
|
||||
final db = await database; |
|
||||
final existingCommandes = await db.query('commandes'); |
|
||||
|
|
||||
if (existingCommandes.isEmpty) { |
|
||||
// Récupérer quelques produits pour créer des commandes |
|
||||
final produits = await db.query('products', limit: 3); |
|
||||
final clients = await db.query('clients', limit: 3); |
|
||||
|
|
||||
if (produits.isNotEmpty && clients.isNotEmpty) { |
|
||||
// Commande 1 |
|
||||
final commande1Id = await db.insert('commandes', { |
|
||||
'clientId': clients[0]['id'], |
|
||||
'dateCommande': DateTime.now().subtract(Duration(days: 5)).toIso8601String(), |
|
||||
'statut': StatutCommande.livree.index, |
|
||||
'montantTotal': 150.0, |
|
||||
'notes': 'Commande urgente', |
|
||||
}); |
|
||||
|
|
||||
await db.insert('details_commandes', { |
|
||||
'commandeId': commande1Id, |
|
||||
'produitId': produits[0]['id'], |
|
||||
'quantite': 2, |
|
||||
'prixUnitaire': 75.0, |
|
||||
'sousTotal': 150.0, |
|
||||
}); |
|
||||
|
|
||||
// Commande 2 |
|
||||
final commande2Id = await db.insert('commandes', { |
|
||||
'clientId': clients[1]['id'], |
|
||||
'dateCommande': DateTime.now().subtract(Duration(days: 2)).toIso8601String(), |
|
||||
'statut': StatutCommande.enPreparation.index, |
|
||||
'montantTotal': 225.0, |
|
||||
'notes': 'Livraison prévue demain', |
|
||||
}); |
|
||||
|
|
||||
if (produits.length > 1) { |
|
||||
await db.insert('details_commandes', { |
|
||||
'commandeId': commande2Id, |
|
||||
'produitId': produits[1]['id'], |
|
||||
'quantite': 3, |
|
||||
'prixUnitaire': 75.0, |
|
||||
'sousTotal': 225.0, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// Commande 3 |
|
||||
final commande3Id = await db.insert('commandes', { |
|
||||
'clientId': clients[2]['id'], |
|
||||
'dateCommande': DateTime.now().subtract(Duration(hours: 6)).toIso8601String(), |
|
||||
'statut': StatutCommande.confirmee.index, |
|
||||
'montantTotal': 300.0, |
|
||||
'notes': 'Commande standard', |
|
||||
}); |
|
||||
|
|
||||
if (produits.length > 2) { |
|
||||
await db.insert('details_commandes', { |
|
||||
'commandeId': commande3Id, |
|
||||
'produitId': produits[2]['id'], |
|
||||
'quantite': 4, |
|
||||
'prixUnitaire': 75.0, |
|
||||
'sousTotal': 300.0, |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
print("Commandes par défaut insérées"); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
Future<void> close() async { |
|
||||
if (_database.isOpen) { |
|
||||
await _database.close(); |
|
||||
} |
|
||||
} |
|
||||
// Ajoutez cette méthode temporaire pour supprimer la DB corrompue |
|
||||
Future<void> deleteDatabaseFile() async { |
|
||||
final documentsDirectory = await getApplicationDocumentsDirectory(); |
|
||||
final path = join(documentsDirectory.path, 'products2.db'); |
|
||||
final file = File(path); |
|
||||
if (await file.exists()) { |
|
||||
await file.delete(); |
|
||||
print("Base de données product supprimée"); |
|
||||
} |
|
||||
} |
|
||||
} |
|
||||
File diff suppressed because it is too large
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, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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,831 @@ |
|||||
|
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'; |
||||
|
|
||||
|
class AjoutPointDeVentePage extends StatefulWidget { |
||||
|
const AjoutPointDeVentePage({super.key}); |
||||
|
|
||||
|
@override |
||||
|
_AjoutPointDeVentePageState createState() => _AjoutPointDeVentePageState(); |
||||
|
} |
||||
|
|
||||
|
class _AjoutPointDeVentePageState extends State<AjoutPointDeVentePage> { |
||||
|
final AppDatabase _appDatabase = AppDatabase.instance; |
||||
|
final _formKey = GlobalKey<FormState>(); |
||||
|
bool _isLoading = false; |
||||
|
|
||||
|
// Contrôleurs |
||||
|
final TextEditingController _nomController = TextEditingController(); |
||||
|
final TextEditingController _codeController = TextEditingController(); |
||||
|
|
||||
|
// Liste des points de vente |
||||
|
List<Map<String, dynamic>> _pointsDeVente = []; |
||||
|
final TextEditingController _searchController = TextEditingController(); |
||||
|
|
||||
|
@override |
||||
|
void initState() { |
||||
|
super.initState(); |
||||
|
_loadPointsDeVente(); |
||||
|
_searchController.addListener(_filterPointsDeVente); |
||||
|
} |
||||
|
|
||||
|
Future<void> _loadPointsDeVente() async { |
||||
|
setState(() { |
||||
|
_isLoading = true; |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
final points = await _appDatabase.getPointsDeVente(); |
||||
|
|
||||
|
// 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(); |
||||
|
if (query.isEmpty) { |
||||
|
_loadPointsDeVente(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
setState(() { |
||||
|
_pointsDeVente = _pointsDeVente.where((point) { |
||||
|
final nom = point['nom']?.toString().toLowerCase() ?? ''; |
||||
|
return nom.contains(query); |
||||
|
}).toList(); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
Future<void> _submitForm() async { |
||||
|
if (_formKey.currentState!.validate()) { |
||||
|
setState(() { |
||||
|
_isLoading = true; |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
await _appDatabase.createPointDeVente( |
||||
|
_nomController.text.trim(), |
||||
|
_codeController.text.trim(), |
||||
|
); |
||||
|
|
||||
|
// Réinitialiser le formulaire |
||||
|
_nomController.clear(); |
||||
|
_codeController.clear(); |
||||
|
|
||||
|
// Recharger la liste |
||||
|
await _loadPointsDeVente(); |
||||
|
|
||||
|
Get.snackbar( |
||||
|
'Succès', |
||||
|
'Point de vente ajouté avec succès', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.green, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} catch (e) { |
||||
|
Get.snackbar( |
||||
|
'Erreur', |
||||
|
'Impossible d\'ajouter le point de vente: ${e.toString()}', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
backgroundColor: Colors.red, |
||||
|
colorText: Colors.white, |
||||
|
); |
||||
|
} finally { |
||||
|
setState(() { |
||||
|
_isLoading = false; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
Future<void> _showConstraintDialog(int id, Map<String, dynamic> verificationResult) async { |
||||
|
final reasons = verificationResult['reasons'] as List<String>; |
||||
|
final suggestions = verificationResult['suggestions'] as List<String>; |
||||
|
|
||||
|
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<void> _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<int>( |
||||
|
value: selectedPointDeVenteId, |
||||
|
decoration: const InputDecoration( |
||||
|
labelText: 'Point de vente de destination', |
||||
|
border: OutlineInputBorder(), |
||||
|
), |
||||
|
items: pointsDeVente.map((pv) => DropdownMenuItem<int>( |
||||
|
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<void> _performTransferAndDelete(int sourceId, int targetId) async { |
||||
|
setState(() { |
||||
|
_isLoading = true; |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
// Afficher un dialog de confirmation final |
||||
|
final confirmed = await Get.dialog<bool>( |
||||
|
AlertDialog( |
||||
|
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'), |
||||
|
), |
||||
|
ElevatedButton( |
||||
|
onPressed: () => Get.back(result: true), |
||||
|
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<void> _showPointDeVenteDetails(Map<String, dynamic> 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<Map<String, int>> _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<void> _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<bool>( |
||||
|
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 = false; |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
appBar: CustomAppBar(title: 'Gestion des points de vente'), |
||||
|
drawer: CustomDrawer(), |
||||
|
body: Column( |
||||
|
children: [ |
||||
|
// Formulaire d'ajout |
||||
|
Card( |
||||
|
margin: const EdgeInsets.all(16), |
||||
|
elevation: 4, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.all(16.0), |
||||
|
child: Form( |
||||
|
key: _formKey, |
||||
|
child: Column( |
||||
|
crossAxisAlignment: CrossAxisAlignment.stretch, |
||||
|
children: [ |
||||
|
const Text( |
||||
|
'Ajouter un point de vente', |
||||
|
style: TextStyle( |
||||
|
fontSize: 18, |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Color.fromARGB(255, 9, 56, 95), |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Champ Nom |
||||
|
TextFormField( |
||||
|
controller: _nomController, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Nom du point de vente', |
||||
|
prefixIcon: const Icon(Icons.store), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey.shade50, |
||||
|
), |
||||
|
validator: (value) { |
||||
|
if (value == null || value.isEmpty) { |
||||
|
return 'Veuillez entrer un nom'; |
||||
|
} |
||||
|
return null; |
||||
|
}, |
||||
|
), |
||||
|
const SizedBox(height: 12), |
||||
|
|
||||
|
// Champ Code |
||||
|
TextFormField( |
||||
|
controller: _codeController, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Code (optionnel)', |
||||
|
prefixIcon: const Icon(Icons.code), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey.shade50, |
||||
|
), |
||||
|
), |
||||
|
const SizedBox(height: 16), |
||||
|
|
||||
|
// Bouton de soumission |
||||
|
ElevatedButton( |
||||
|
onPressed: _isLoading ? null : _submitForm, |
||||
|
|
||||
|
style: ElevatedButton.styleFrom( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 16), |
||||
|
backgroundColor: Colors.blue.shade800, |
||||
|
foregroundColor: Colors.white, |
||||
|
shape: RoundedRectangleBorder( |
||||
|
borderRadius: BorderRadius.circular(12), |
||||
|
), |
||||
|
), |
||||
|
child: _isLoading |
||||
|
? const SizedBox( |
||||
|
width: 20, |
||||
|
height: 20, |
||||
|
child: CircularProgressIndicator( |
||||
|
strokeWidth: 2, |
||||
|
color: Colors.white, |
||||
|
), |
||||
|
) |
||||
|
: const Text( |
||||
|
'Ajouter le point de vente', |
||||
|
style: TextStyle(fontSize: 16), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Liste des points de vente |
||||
|
Expanded( |
||||
|
child: Padding( |
||||
|
padding: const EdgeInsets.symmetric(horizontal: 16), |
||||
|
child: Column( |
||||
|
children: [ |
||||
|
// Barre de recherche |
||||
|
Padding( |
||||
|
padding: const EdgeInsets.only(bottom: 8), |
||||
|
child: TextField( |
||||
|
controller: _searchController, |
||||
|
decoration: InputDecoration( |
||||
|
labelText: 'Rechercher un point de vente', |
||||
|
prefixIcon: const Icon(Icons.search), |
||||
|
border: OutlineInputBorder( |
||||
|
borderRadius: BorderRadius.circular(8), |
||||
|
), |
||||
|
filled: true, |
||||
|
fillColor: Colors.grey.shade50, |
||||
|
suffixIcon: _searchController.text.isNotEmpty |
||||
|
? IconButton( |
||||
|
icon: const Icon(Icons.clear), |
||||
|
onPressed: () { |
||||
|
_searchController.clear(); |
||||
|
_loadPointsDeVente(); |
||||
|
}, |
||||
|
) |
||||
|
: null, |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// En-tête de liste |
||||
|
Padding( |
||||
|
padding: const EdgeInsets.symmetric(vertical: 8), |
||||
|
child: Row( |
||||
|
children: [ |
||||
|
const Expanded( |
||||
|
flex: 2, |
||||
|
child: Text( |
||||
|
'Nom', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Color.fromARGB(255, 9, 56, 95), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
const Expanded( |
||||
|
child: Text( |
||||
|
'Code', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Color.fromARGB(255, 9, 56, 95), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
SizedBox( |
||||
|
width: 40, |
||||
|
child: Text( |
||||
|
'Actions', |
||||
|
style: TextStyle( |
||||
|
fontWeight: FontWeight.bold, |
||||
|
color: Color.fromARGB(255, 9, 56, 95), |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
|
||||
|
// Liste |
||||
|
Expanded( |
||||
|
child: _isLoading && _pointsDeVente.isEmpty |
||||
|
? const Center(child: CircularProgressIndicator()) |
||||
|
: _pointsDeVente.isEmpty |
||||
|
? const Center( |
||||
|
child: Column( |
||||
|
mainAxisAlignment: MainAxisAlignment.center, |
||||
|
children: [ |
||||
|
Icon(Icons.store_mall_directory_outlined, |
||||
|
size: 60, color: Colors.grey), |
||||
|
SizedBox(height: 16), |
||||
|
Text( |
||||
|
'Aucun point de vente trouvé', |
||||
|
style: TextStyle( |
||||
|
fontSize: 16, |
||||
|
color: Colors.grey), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
) |
||||
|
: ListView.builder( |
||||
|
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)', |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
}, |
||||
|
) |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void dispose() { |
||||
|
_nomController.dispose(); |
||||
|
_codeController.dispose(); |
||||
|
_searchController.dispose(); |
||||
|
super.dispose(); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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), |
||||
|
), |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
], |
||||
|
], |
||||
|
), |
||||
|
), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,44 @@ |
|||||
|
import 'package:flutter/material.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import 'package:youmazgestion/Components/appDrawer.dart'; |
||||
|
import 'package:youmazgestion/Views/historique.dart'; |
||||
|
void main() { |
||||
|
runApp(const MyApp()); |
||||
|
} |
||||
|
|
||||
|
class MyApp extends StatelessWidget { |
||||
|
const MyApp({super.key}); |
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return GetMaterialApp( |
||||
|
title: 'Youmaz Gestion', |
||||
|
theme: ThemeData( |
||||
|
primarySwatch: Colors.blue, |
||||
|
visualDensity: VisualDensity.adaptivePlatformDensity, |
||||
|
), |
||||
|
home: const MainLayout(), |
||||
|
debugShowCheckedModeBanner: false, |
||||
|
); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
class MainLayout extends StatefulWidget { |
||||
|
const MainLayout({super.key}); |
||||
|
|
||||
|
@override |
||||
|
State<MainLayout> createState() => _MainLayoutState(); |
||||
|
} |
||||
|
|
||||
|
class _MainLayoutState extends State<MainLayout> { |
||||
|
// Index par défaut pour la page de commande |
||||
|
|
||||
|
|
||||
|
@override |
||||
|
Widget build(BuildContext context) { |
||||
|
return Scaffold( |
||||
|
drawer: CustomDrawer(), |
||||
|
body: const HistoriquePage(), |
||||
|
); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -0,0 +1,64 @@ |
|||||
|
// Config/database_config.dart - Version améliorée |
||||
|
class DatabaseConfig { |
||||
|
static const String host = 'localhost'; |
||||
|
static const int port = 3306; |
||||
|
static const String username = 'root'; |
||||
|
static const String? password = null; |
||||
|
static const String database = 'gico'; |
||||
|
|
||||
|
static const String prodHost = '185.70.105.157'; |
||||
|
static const String prodUsername = 'guycom'; |
||||
|
static const String prodPassword = '3iV59wjRdbuXAPR'; |
||||
|
static const String prodDatabase = 'guycom'; |
||||
|
|
||||
|
static const Duration connectionTimeout = Duration(seconds: 30); |
||||
|
static const Duration queryTimeout = Duration(seconds: 15); |
||||
|
|
||||
|
static const int maxConnections = 10; |
||||
|
static const int minConnections = 2; |
||||
|
|
||||
|
static bool get isDevelopment => true; |
||||
|
|
||||
|
static Map<String, dynamic> getConfig() { |
||||
|
if (isDevelopment) { |
||||
|
return { |
||||
|
'host': host, |
||||
|
'port': port, |
||||
|
'user': username, |
||||
|
'password': password, |
||||
|
'database': database, |
||||
|
'timeout': connectionTimeout.inSeconds, |
||||
|
}; |
||||
|
} else { |
||||
|
return { |
||||
|
'host': prodHost, |
||||
|
'port': port, |
||||
|
'user': prodUsername, |
||||
|
'password': prodPassword, |
||||
|
'database': prodDatabase, |
||||
|
'timeout': connectionTimeout.inSeconds, |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Validation de la configuration |
||||
|
static bool validateConfig() { |
||||
|
try { |
||||
|
final config = getConfig(); |
||||
|
return config['host']?.toString().isNotEmpty == true && |
||||
|
config['database']?.toString().isNotEmpty == true && |
||||
|
config['user'] != null; |
||||
|
} catch (e) { |
||||
|
print("Erreur de validation de la configuration: $e"); |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// Configuration avec retry automatique |
||||
|
static Map<String, dynamic> getConfigWithRetry() { |
||||
|
final config = getConfig(); |
||||
|
config['retryCount'] = 3; |
||||
|
config['retryDelay'] = 5000; // ms |
||||
|
return config; |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue