feat: Release v3.1.6 - Amélioration complète des flux de passages

- Optimisation des listes de passages (user/admin)
- Amélioration du flux de création avec validation temps réel
- Amélioration du flux de consultation avec export multi-formats
- Amélioration du flux de modification avec suivi des changements
- Ajout de la génération PDF pour les reçus
- Migration de la structure des uploads
- Implémentation de la file d'attente d'emails
- Ajout des permissions de suppression de passages
- Corrections de bugs et optimisations performances

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-21 17:57:27 +02:00
parent cdb676ea71
commit 6d41a1274f
24 changed files with 4605 additions and 1082 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,420 @@
import 'package:flutter/material.dart';
import 'package:geosector_app/core/data/models/passage_model.dart';
import 'package:geosector_app/core/constants/app_keys.dart';
import 'package:geosector_app/core/repositories/passage_repository.dart';
import 'package:geosector_app/core/utils/api_exception.dart';
import 'package:geosector_app/core/services/current_amicale_service.dart';
import 'package:geosector_app/app.dart';
class PassageMapDialog extends StatelessWidget {
final PassageModel passage;
final bool isAdmin;
final VoidCallback? onDeleted;
const PassageMapDialog({
super.key,
required this.passage,
this.isAdmin = false,
this.onDeleted,
});
@override
Widget build(BuildContext context) {
final int type = passage.fkType;
// Récupérer le type de passage
final String typePassage = AppKeys.typesPassages[type]?['titre'] ?? 'Inconnu';
final Color typeColor = Color(AppKeys.typesPassages[type]?['couleur1'] ?? 0xFF9E9E9E);
// Construire l'adresse complète
final String adresse = '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim();
// Informations sur l'étage, l'appartement et la résidence (si habitat = 2)
String? etageInfo;
String? apptInfo;
String? residenceInfo;
if (passage.fkHabitat == 2) {
if (passage.niveau.isNotEmpty) {
etageInfo = 'Étage ${passage.niveau}';
}
if (passage.appt.isNotEmpty) {
apptInfo = 'Appt. ${passage.appt}';
}
if (passage.residence.isNotEmpty) {
residenceInfo = passage.residence;
}
}
// Formater la date (uniquement si le type n'est pas 2 et si la date existe)
String? dateInfo;
if (type != 2 && passage.passedAt != null) {
final date = passage.passedAt!;
dateInfo = '${_formatDate(date)} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}';
}
// Récupérer le nom du passage (si le type n'est pas 6 - Maison vide)
String? nomInfo;
if (type != 6 && passage.name.isNotEmpty) {
nomInfo = passage.name;
}
// Récupérer les informations de règlement si le type est 1 (Effectué) ou 5 (Lot)
Widget? reglementInfo;
if ((type == 1 || type == 5) && passage.fkTypeReglement > 0) {
final int typeReglementId = passage.fkTypeReglement;
final String montant = passage.montant;
// Récupérer les informations du type de règlement
if (AppKeys.typesReglements.containsKey(typeReglementId)) {
final Map<String, dynamic> typeReglement = AppKeys.typesReglements[typeReglementId]!;
final String titre = typeReglement['titre'] as String;
final Color couleur = Color(typeReglement['couleur'] as int);
final IconData iconData = typeReglement['icon_data'] as IconData;
reglementInfo = Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(top: 8),
decoration: BoxDecoration(
color: couleur.withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
border: Border.all(color: couleur.withOpacity(0.3)),
),
child: Row(
children: [
Icon(iconData, color: couleur, size: 20),
const SizedBox(width: 8),
Text('$titre: $montant',
style: TextStyle(color: couleur, fontWeight: FontWeight.bold)),
],
),
);
}
}
// Vérifier si l'utilisateur peut supprimer (admin ou user avec permission)
bool canDelete = isAdmin;
if (!isAdmin) {
try {
final amicale = CurrentAmicaleService.instance.currentAmicale;
if (amicale != null) {
canDelete = amicale.chkUserDeletePass == true;
}
} catch (e) {
debugPrint('Erreur lors de la vérification des permissions: $e');
}
}
return AlertDialog(
title: Row(
children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: typeColor,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Passage #${passage.id}',
style: const TextStyle(fontSize: 18),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: typeColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
typePassage,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: typeColor,
),
),
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Afficher en premier si le passage n'est pas affecté à un secteur
if (passage.fkSector == null) ...[
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.red.withOpacity(0.1),
border: Border.all(color: Colors.red, width: 1),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const Icon(Icons.warning, color: Colors.red, size: 20),
const SizedBox(width: 8),
const Expanded(
child: Text(
'Ce passage n\'est plus affecté à un secteur',
style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold),
),
),
],
),
),
],
// Adresse
_buildInfoRow(Icons.location_on, 'Adresse', adresse.isEmpty ? 'Non renseignée' : adresse),
// Résidence
if (residenceInfo != null)
_buildInfoRow(Icons.apartment, 'Résidence', residenceInfo),
// Étage et appartement
if (etageInfo != null || apptInfo != null)
_buildInfoRow(Icons.stairs, 'Localisation',
[etageInfo, apptInfo].where((e) => e != null).join(' - ')),
// Date
if (dateInfo != null)
_buildInfoRow(Icons.calendar_today, 'Date', dateInfo),
// Nom
if (nomInfo != null)
_buildInfoRow(Icons.person, 'Nom', nomInfo),
// Ville
if (passage.ville.isNotEmpty)
_buildInfoRow(Icons.location_city, 'Ville', passage.ville),
// Remarque
if (passage.remarque.isNotEmpty)
_buildInfoRow(Icons.note, 'Remarque', passage.remarque),
// Règlement
if (reglementInfo != null) reglementInfo,
],
),
),
actions: [
// Bouton de suppression si autorisé
if (canDelete)
TextButton.icon(
onPressed: () {
Navigator.of(context).pop();
_showDeleteConfirmationDialog(context);
},
icon: const Icon(Icons.delete, size: 20),
label: const Text('Supprimer'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
),
// Bouton de fermeture
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Fermer'),
),
],
);
}
// Helper pour construire une ligne d'information
Widget _buildInfoRow(IconData icon, String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 16, color: Colors.grey[600]),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: const TextStyle(color: Colors.black87),
children: [
TextSpan(
text: '$label: ',
style: const TextStyle(fontWeight: FontWeight.w600),
),
TextSpan(text: value),
],
),
),
),
],
),
);
}
// Formater une date
String _formatDate(DateTime date) {
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
}
// Afficher le dialog de confirmation de suppression
void _showDeleteConfirmationDialog(BuildContext context) {
final TextEditingController confirmController = TextEditingController();
final String streetNumber = passage.numero ?? '';
final String fullAddress = '${passage.numero ?? ''} ${passage.rueBis ?? ''} ${passage.rue ?? ''}'.trim();
showDialog(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Row(
children: [
Icon(Icons.warning, color: Colors.red, size: 28),
SizedBox(width: 8),
Text('Confirmation de suppression'),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ATTENTION : Cette action est irréversible !',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Colors.red,
fontSize: 16,
),
),
const SizedBox(height: 16),
Text(
'Vous êtes sur le point de supprimer définitivement le passage :',
style: TextStyle(color: Colors.grey[800]),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey[300]!),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fullAddress.isEmpty ? 'Adresse inconnue' : fullAddress,
style: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
if (passage.ville.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
passage.ville,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
],
),
),
const SizedBox(height: 20),
const Text(
'Pour confirmer la suppression, veuillez saisir le numéro de rue de ce passage :',
style: TextStyle(fontWeight: FontWeight.w500),
),
const SizedBox(height: 12),
TextField(
controller: confirmController,
decoration: InputDecoration(
labelText: 'Numéro de rue',
hintText: streetNumber.isNotEmpty ? 'Ex: $streetNumber' : 'Saisir le numéro',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.home),
),
keyboardType: TextInputType.text,
textCapitalization: TextCapitalization.characters,
),
],
),
),
actions: [
TextButton(
onPressed: () {
confirmController.dispose();
Navigator.of(dialogContext).pop();
},
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () async {
// Vérifier que le numéro saisi correspond
final enteredNumber = confirmController.text.trim();
if (enteredNumber.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez saisir le numéro de rue'),
backgroundColor: Colors.orange,
),
);
return;
}
if (streetNumber.isNotEmpty && enteredNumber.toUpperCase() != streetNumber.toUpperCase()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Le numéro de rue ne correspond pas'),
backgroundColor: Colors.red,
),
);
return;
}
// Fermer le dialog
confirmController.dispose();
Navigator.of(dialogContext).pop();
// Effectuer la suppression
await _deletePassage(context);
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Supprimer définitivement'),
),
],
);
},
);
}
// Supprimer un passage
Future<void> _deletePassage(BuildContext context) async {
try {
// Appeler le repository pour supprimer via l'API
final success = await passageRepository.deletePassageViaApi(passage.id);
if (success && context.mounted) {
ApiException.showSuccess(context, 'Passage supprimé avec succès');
// Appeler le callback si fourni
onDeleted?.call();
} else if (context.mounted) {
ApiException.showError(context, Exception('Erreur lors de la suppression'));
}
} catch (e) {
debugPrint('Erreur suppression passage: $e');
if (context.mounted) {
ApiException.showError(context, e);
}
}
}
}