- Ajout du service PasswordSecurityService conforme NIST SP 800-63B - Vérification des mots de passe contre la base Have I Been Pwned - Validation : minimum 8 caractères, maximum 64 caractères - Pas d'exigences de composition obligatoires (conforme NIST) - Intégration dans LoginController et UserController - Génération de mots de passe sécurisés non compromis 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1390 lines
46 KiB
Dart
Executable File
1390 lines
46 KiB
Dart
Executable File
import 'package:geosector_app/app.dart'; // Pour accéder aux instances globales
|
|
import 'package:flutter/material.dart';
|
|
import 'package:hive/hive.dart';
|
|
import 'package:geosector_app/core/constants/app_keys.dart';
|
|
import 'package:geosector_app/core/data/models/passage_model.dart';
|
|
import 'package:geosector_app/core/data/models/sector_model.dart';
|
|
import 'package:geosector_app/core/data/models/membre_model.dart';
|
|
import 'package:geosector_app/core/repositories/passage_repository.dart';
|
|
import 'package:geosector_app/core/repositories/sector_repository.dart';
|
|
import 'package:geosector_app/core/repositories/user_repository.dart';
|
|
import 'package:geosector_app/core/repositories/membre_repository.dart';
|
|
import 'package:geosector_app/presentation/widgets/passages/passages_list_widget.dart';
|
|
import 'package:geosector_app/presentation/widgets/passage_form_dialog.dart';
|
|
import 'dart:math' as math;
|
|
|
|
/// Class pour dessiner les petits points blancs sur le fond
|
|
class DotsPainter extends CustomPainter {
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
final paint = Paint()
|
|
..color = Colors.white.withOpacity(0.5)
|
|
..style = PaintingStyle.fill;
|
|
|
|
final random = math.Random(42); // Seed fixe pour consistance
|
|
final numberOfDots = (size.width * size.height) ~/ 1500;
|
|
|
|
for (int i = 0; i < numberOfDots; i++) {
|
|
final x = random.nextDouble() * size.width;
|
|
final y = random.nextDouble() * size.height;
|
|
final radius = 1.0 + random.nextDouble() * 2.0;
|
|
canvas.drawCircle(Offset(x, y), radius, paint);
|
|
}
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
|
}
|
|
|
|
class AdminHistoryPage extends StatefulWidget {
|
|
const AdminHistoryPage({super.key});
|
|
|
|
@override
|
|
State<AdminHistoryPage> createState() => _AdminHistoryPageState();
|
|
}
|
|
|
|
class _AdminHistoryPageState extends State<AdminHistoryPage> {
|
|
// État des filtres
|
|
String searchQuery = '';
|
|
String selectedSector = 'Tous';
|
|
String selectedUser = 'Tous';
|
|
String selectedType = 'Tous';
|
|
String selectedPaymentMethod = 'Tous';
|
|
String selectedPeriod = 'Tous'; // Période par défaut
|
|
DateTimeRange? selectedDateRange;
|
|
|
|
// Contrôleur pour la recherche
|
|
final TextEditingController _searchController = TextEditingController();
|
|
|
|
// IDs pour les filtres
|
|
int? selectedSectorId;
|
|
int? selectedUserId;
|
|
|
|
// Listes pour les filtres
|
|
List<SectorModel> _sectors = [];
|
|
List<MembreModel> _membres = [];
|
|
|
|
// Repositories
|
|
late PassageRepository _passageRepository;
|
|
late SectorRepository _sectorRepository;
|
|
late UserRepository _userRepository;
|
|
late MembreRepository _membreRepository;
|
|
|
|
// Passages formatés pour l'affichage
|
|
List<Map<String, dynamic>> _formattedPassages = [];
|
|
|
|
// Passages originaux pour l'édition
|
|
List<PassageModel> _originalPassages = [];
|
|
|
|
// État de chargement
|
|
bool _isLoading = true;
|
|
String _errorMessage = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Initialiser les filtres
|
|
_initializeFilters();
|
|
// Charger les filtres présélectionnés depuis Hive si disponibles
|
|
_loadPreselectedFilters();
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
// Récupérer les repositories une seule fois
|
|
_loadRepositories();
|
|
}
|
|
|
|
// Charger les repositories et les données
|
|
void _loadRepositories() {
|
|
try {
|
|
// Utiliser les instances globales définies dans app.dart
|
|
_passageRepository = passageRepository;
|
|
_userRepository = userRepository;
|
|
_sectorRepository = sectorRepository;
|
|
_membreRepository = membreRepository;
|
|
|
|
// Charger les secteurs et les membres
|
|
_loadSectorsAndMembres();
|
|
|
|
// Charger les passages
|
|
_loadPassages();
|
|
} catch (e) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_errorMessage = 'Erreur lors du chargement des repositories: $e';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Charger les secteurs et les membres
|
|
void _loadSectorsAndMembres() {
|
|
try {
|
|
// Récupérer la liste des secteurs
|
|
_sectors = _sectorRepository.getAllSectors();
|
|
debugPrint('Nombre de secteurs récupérés: ${_sectors.length}');
|
|
|
|
// Récupérer la liste des membres
|
|
_membres = _membreRepository.getAllMembres();
|
|
debugPrint('Nombre de membres récupérés: ${_membres.length}');
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du chargement des secteurs et membres: $e');
|
|
}
|
|
}
|
|
|
|
// Charger les passages
|
|
void _loadPassages() {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
// Récupérer les passages
|
|
final List<PassageModel> allPassages =
|
|
_passageRepository.getAllPassages();
|
|
|
|
// Stocker les passages originaux pour l'édition
|
|
_originalPassages = allPassages;
|
|
|
|
// Convertir les passages en format attendu par PassagesListWidget
|
|
_formattedPassages = _formatPassagesForWidget(
|
|
allPassages, _sectorRepository, _membreRepository);
|
|
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
_errorMessage = 'Erreur lors du chargement des passages: $e';
|
|
});
|
|
}
|
|
}
|
|
|
|
// Initialiser les filtres
|
|
void _initializeFilters() {
|
|
// Par défaut, on n'applique pas de filtre par utilisateur ou secteur
|
|
selectedSectorId = null;
|
|
selectedUserId = null;
|
|
|
|
// Période par défaut : toutes les périodes
|
|
selectedPeriod = 'Tous';
|
|
|
|
// Plage de dates par défaut : aucune restriction
|
|
selectedDateRange = null;
|
|
}
|
|
|
|
// Charger les filtres présélectionnés depuis Hive
|
|
void _loadPreselectedFilters() {
|
|
try {
|
|
// Utiliser Hive directement sans async
|
|
if (Hive.isBoxOpen(AppKeys.settingsBoxName)) {
|
|
final settingsBox = Hive.box(AppKeys.settingsBoxName);
|
|
|
|
// Charger le secteur présélectionné
|
|
final int? preselectedSectorId = settingsBox.get('history_selectedSectorId');
|
|
final String? preselectedSectorName = settingsBox.get('history_selectedSectorName');
|
|
final int? preselectedTypeId = settingsBox.get('history_selectedTypeId');
|
|
|
|
if (preselectedSectorId != null && preselectedSectorName != null) {
|
|
selectedSectorId = preselectedSectorId;
|
|
selectedSector = preselectedSectorName;
|
|
|
|
debugPrint('Secteur présélectionné: $preselectedSectorName (ID: $preselectedSectorId)');
|
|
}
|
|
|
|
if (preselectedTypeId != null) {
|
|
selectedType = preselectedTypeId.toString();
|
|
debugPrint('Type de passage présélectionné: $preselectedTypeId');
|
|
}
|
|
|
|
// Nettoyer les valeurs après utilisation pour ne pas les réutiliser la prochaine fois
|
|
settingsBox.delete('history_selectedSectorId');
|
|
settingsBox.delete('history_selectedSectorName');
|
|
settingsBox.delete('history_selectedTypeId');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du chargement des filtres présélectionnés: $e');
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// Méthode pour appliquer tous les filtres
|
|
List<Map<String, dynamic>> _getFilteredPassages() {
|
|
try {
|
|
var filtered = _formattedPassages.where((passage) {
|
|
try {
|
|
// Ne plus exclure automatiquement les passages de type 2
|
|
// car on propose maintenant un filtre par type dans les "Filtres avancés"
|
|
|
|
// Filtrer par utilisateur
|
|
if (selectedUserId != null &&
|
|
passage.containsKey('fkUser') &&
|
|
passage['fkUser'] != selectedUserId) {
|
|
return false;
|
|
}
|
|
|
|
// Filtrer par secteur
|
|
if (selectedSectorId != null &&
|
|
passage.containsKey('fkSector') &&
|
|
passage['fkSector'] != selectedSectorId) {
|
|
return false;
|
|
}
|
|
|
|
// Filtrer par type de passage
|
|
if (selectedType != 'Tous') {
|
|
try {
|
|
final int? selectedTypeId = int.tryParse(selectedType);
|
|
if (selectedTypeId != null) {
|
|
if (!passage.containsKey('type') ||
|
|
passage['type'] != selectedTypeId) {
|
|
return false;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur de filtrage par type: $e');
|
|
}
|
|
}
|
|
|
|
// Filtrer par mode de règlement
|
|
if (selectedPaymentMethod != 'Tous') {
|
|
try {
|
|
final int? selectedPaymentId =
|
|
int.tryParse(selectedPaymentMethod);
|
|
if (selectedPaymentId != null) {
|
|
if (!passage.containsKey('payment') ||
|
|
passage['payment'] != selectedPaymentId) {
|
|
return false;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur de filtrage par mode de règlement: $e');
|
|
}
|
|
}
|
|
|
|
// Filtrer par recherche
|
|
if (searchQuery.isNotEmpty) {
|
|
try {
|
|
final query = searchQuery.toLowerCase();
|
|
final address = passage.containsKey('address')
|
|
? passage['address']?.toString().toLowerCase() ?? ''
|
|
: '';
|
|
final name = passage.containsKey('name')
|
|
? passage['name']?.toString().toLowerCase() ?? ''
|
|
: '';
|
|
final notes = passage.containsKey('notes')
|
|
? passage['notes']?.toString().toLowerCase() ?? ''
|
|
: '';
|
|
|
|
if (!address.contains(query) &&
|
|
!name.contains(query) &&
|
|
!notes.contains(query)) {
|
|
return false;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur de filtrage par recherche: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Filtrer par période/date
|
|
if (selectedDateRange != null) {
|
|
try {
|
|
if (passage.containsKey('date') && passage['date'] is DateTime) {
|
|
final DateTime passageDate = passage['date'] as DateTime;
|
|
if (passageDate.isBefore(selectedDateRange!.start) ||
|
|
passageDate.isAfter(selectedDateRange!.end)) {
|
|
return false;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur de filtrage par date: $e');
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (e) {
|
|
debugPrint('Erreur lors du filtrage d\'un passage: $e');
|
|
return false;
|
|
}
|
|
}).toList();
|
|
|
|
// Trier par date décroissante (plus récent en premier)
|
|
filtered.sort((a, b) {
|
|
try {
|
|
final DateTime dateA = a['date'] as DateTime;
|
|
final DateTime dateB = b['date'] as DateTime;
|
|
return dateB.compareTo(dateA);
|
|
} catch (e) {
|
|
return 0;
|
|
}
|
|
});
|
|
|
|
debugPrint(
|
|
'Passages filtrés: ${filtered.length}/${_formattedPassages.length}');
|
|
return filtered;
|
|
} catch (e) {
|
|
debugPrint('Erreur globale lors du filtrage: $e');
|
|
return _formattedPassages;
|
|
}
|
|
}
|
|
|
|
// Mettre à jour le filtre par secteur
|
|
void _updateSectorFilter(String sectorName, int? sectorId) {
|
|
setState(() {
|
|
selectedSector = sectorName;
|
|
selectedSectorId = sectorId;
|
|
});
|
|
}
|
|
|
|
// Mettre à jour le filtre par utilisateur
|
|
void _updateUserFilter(String userName, int? userId) {
|
|
setState(() {
|
|
selectedUser = userName;
|
|
selectedUserId = userId;
|
|
});
|
|
}
|
|
|
|
// Mettre à jour le filtre par période
|
|
void _updatePeriodFilter(String period) {
|
|
setState(() {
|
|
selectedPeriod = period;
|
|
|
|
// Mettre à jour la plage de dates en fonction de la période
|
|
final DateTime now = DateTime.now();
|
|
|
|
switch (period) {
|
|
case 'Derniers 15 jours':
|
|
selectedDateRange = DateTimeRange(
|
|
start: now.subtract(const Duration(days: 15)),
|
|
end: now,
|
|
);
|
|
break;
|
|
case 'Dernière semaine':
|
|
selectedDateRange = DateTimeRange(
|
|
start: now.subtract(const Duration(days: 7)),
|
|
end: now,
|
|
);
|
|
break;
|
|
case 'Dernier mois':
|
|
selectedDateRange = DateTimeRange(
|
|
start: DateTime(now.year, now.month - 1, now.day),
|
|
end: now,
|
|
);
|
|
break;
|
|
case 'Tous':
|
|
selectedDateRange = null;
|
|
break;
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
// Afficher un widget de chargement ou d'erreur si nécessaire
|
|
if (_isLoading) {
|
|
return Stack(
|
|
children: [
|
|
// Fond dégradé avec petits points blancs
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.white, Colors.red.shade300],
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: DotsPainter(),
|
|
child: const SizedBox(
|
|
width: double.infinity, height: double.infinity),
|
|
),
|
|
),
|
|
const Center(
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
if (_errorMessage.isNotEmpty) {
|
|
return _buildErrorWidget(_errorMessage);
|
|
}
|
|
|
|
// Retourner le widget principal avec les données chargées
|
|
return Stack(
|
|
children: [
|
|
// Fond dégradé avec petits points blancs
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.white, Colors.red.shade300],
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: DotsPainter(),
|
|
child:
|
|
const SizedBox(width: double.infinity, height: double.infinity),
|
|
),
|
|
),
|
|
// Contenu de la page
|
|
LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final passages = _getFilteredPassages();
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
minHeight: constraints.maxHeight - 32, // Moins le padding
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Titre de la page
|
|
Text(
|
|
'Historique des passages',
|
|
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: Theme.of(context).colorScheme.primary,
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Filtres supplémentaires (secteur, utilisateur, période)
|
|
_buildAdditionalFilters(context),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// Widget de liste des passages avec hauteur fixe
|
|
SizedBox(
|
|
height: constraints.maxHeight * 0.7, // 70% de la hauteur disponible
|
|
child: PassagesListWidget(
|
|
passages: passages,
|
|
showFilters:
|
|
false, // Désactivé car les filtres sont maintenant dans la card "Filtres avancés"
|
|
showSearch:
|
|
false, // Désactivé car la recherche est maintenant dans la card "Filtres avancés"
|
|
showActions: true,
|
|
// Ne plus passer les filtres individuels car ils sont maintenant appliqués dans _getFilteredPassages()
|
|
onPassageSelected: (passage) {
|
|
_openPassageEditDialog(context, passage);
|
|
},
|
|
onReceiptView: (passage) {
|
|
_showReceiptDialog(context, passage);
|
|
},
|
|
onDetailsView: (passage) {
|
|
_showDetailsDialog(context, passage);
|
|
},
|
|
onPassageEdit: (passage) {
|
|
// Action pour modifier le passage
|
|
// Cette fonctionnalité pourrait être implémentée ultérieurement
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Widget d'erreur pour afficher un message d'erreur
|
|
Widget _buildErrorWidget(String message) {
|
|
return Stack(
|
|
children: [
|
|
// Fond dégradé avec petits points blancs
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [Colors.white, Colors.red.shade300],
|
|
),
|
|
),
|
|
child: CustomPaint(
|
|
painter: DotsPainter(),
|
|
child:
|
|
const SizedBox(width: double.infinity, height: double.infinity),
|
|
),
|
|
),
|
|
Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(
|
|
Icons.error_outline,
|
|
color: Colors.red,
|
|
size: 64,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Erreur',
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.red[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
message,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontSize: 16),
|
|
),
|
|
const SizedBox(height: 24),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
// Recharger la page
|
|
setState(() {});
|
|
},
|
|
child: const Text('Réessayer'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Convertir les passages du modèle Hive vers le format attendu par le widget
|
|
List<Map<String, dynamic>> _formatPassagesForWidget(
|
|
List<PassageModel> passages,
|
|
SectorRepository sectorRepository,
|
|
MembreRepository membreRepository) {
|
|
return passages.map((passage) {
|
|
// Récupérer le secteur associé au passage (si fkSector n'est pas null)
|
|
final SectorModel? sector = passage.fkSector != null
|
|
? sectorRepository.getSectorById(passage.fkSector!)
|
|
: null;
|
|
|
|
// Récupérer le membre associé au passage
|
|
final MembreModel? membre =
|
|
membreRepository.getMembreById(passage.fkUser);
|
|
|
|
// Construire l'adresse complète
|
|
final String address =
|
|
'${passage.numero} ${passage.rue}${passage.rueBis.isNotEmpty ? ' ${passage.rueBis}' : ''}, ${passage.ville}';
|
|
|
|
// Déterminer si le passage a une erreur d'envoi de reçu
|
|
final bool hasError = passage.emailErreur.isNotEmpty;
|
|
|
|
return {
|
|
'id': passage.id,
|
|
if (passage.passedAt != null) 'date': passage.passedAt!,
|
|
'address': address, // Adresse complète pour l'affichage
|
|
// Champs séparés pour l'édition
|
|
'numero': passage.numero,
|
|
'rueBis': passage.rueBis,
|
|
'rue': passage.rue,
|
|
'ville': passage.ville,
|
|
'residence': passage.residence,
|
|
'appt': passage.appt,
|
|
'niveau': passage.niveau,
|
|
'fkHabitat': passage.fkHabitat,
|
|
'fkSector': passage.fkSector,
|
|
'sector': sector?.libelle ?? 'Secteur inconnu',
|
|
'fkUser': passage.fkUser,
|
|
'user': membre?.name ?? 'Membre inconnu',
|
|
'type': passage.fkType,
|
|
'amount': double.tryParse(passage.montant) ?? 0.0,
|
|
'payment': passage.fkTypeReglement,
|
|
'email': passage.email,
|
|
'hasReceipt': passage.nomRecu.isNotEmpty,
|
|
'hasError': hasError,
|
|
'notes': passage.remarque,
|
|
'name': passage.name,
|
|
'phone': passage.phone,
|
|
'montant': passage.montant,
|
|
'remarque': passage.remarque,
|
|
// Autres champs utiles
|
|
'fkOperation': passage.fkOperation,
|
|
'passedAt': passage.passedAt,
|
|
'lastSyncedAt': passage.lastSyncedAt,
|
|
'isActive': passage.isActive,
|
|
'isSynced': passage.isSynced,
|
|
};
|
|
}).toList();
|
|
}
|
|
|
|
void _showReceiptDialog(BuildContext context, Map<String, dynamic> passage) {
|
|
final int passageId = passage['id'] as int;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('Reçu du passage #$passageId'),
|
|
content: const SizedBox(
|
|
width: 500,
|
|
height: 600,
|
|
child: Center(
|
|
child: Text('Aperçu du reçu PDF'),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Fermer'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
// Action pour télécharger le reçu
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text('Télécharger'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showDetailsDialog(BuildContext context, Map<String, dynamic> passage) {
|
|
final int passageId = passage['id'] as int;
|
|
final DateTime date = passage['date'] as DateTime;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('Détails du passage #$passageId'),
|
|
content: SizedBox(
|
|
width: 500,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_buildDetailRow('Date',
|
|
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}'),
|
|
_buildDetailRow('Adresse', passage['address'] as String),
|
|
_buildDetailRow('Secteur', passage['sector'] as String),
|
|
_buildDetailRow('Collecteur', passage['user'] as String),
|
|
_buildDetailRow(
|
|
'Type',
|
|
AppKeys.typesPassages[passage['type']]?['titre'] ??
|
|
'Inconnu'),
|
|
_buildDetailRow('Montant', '${passage['amount']} €'),
|
|
_buildDetailRow(
|
|
'Mode de paiement',
|
|
AppKeys.typesReglements[passage['payment']]?['titre'] ??
|
|
'Inconnu'),
|
|
_buildDetailRow('Email', passage['email'] as String),
|
|
_buildDetailRow(
|
|
'Reçu envoyé', passage['hasReceipt'] ? 'Oui' : 'Non'),
|
|
_buildDetailRow(
|
|
'Erreur d\'envoi', passage['hasError'] ? 'Oui' : 'Non'),
|
|
_buildDetailRow(
|
|
'Notes',
|
|
(passage['notes'] as String).isEmpty
|
|
? '-'
|
|
: passage['notes'] as String),
|
|
const SizedBox(height: 16),
|
|
const Text(
|
|
'Historique des actions',
|
|
style: TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHistoryItem(
|
|
date,
|
|
passage['user'] as String,
|
|
'Création du passage',
|
|
),
|
|
if (passage['hasReceipt'])
|
|
_buildHistoryItem(
|
|
date.add(const Duration(minutes: 5)),
|
|
'Système',
|
|
'Envoi du reçu par email',
|
|
),
|
|
if (passage['hasError'])
|
|
_buildHistoryItem(
|
|
date.add(const Duration(minutes: 6)),
|
|
'Système',
|
|
'Erreur lors de l\'envoi du reçu',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Fermer'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
// Action pour modifier le passage
|
|
Navigator.pop(context);
|
|
},
|
|
child: const Text('Modifier'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _openPassageEditDialog(
|
|
BuildContext context, Map<String, dynamic> passage) async {
|
|
try {
|
|
debugPrint('=== DEBUT _openPassageEditDialog ===');
|
|
|
|
// Récupérer l'ID du passage
|
|
final int passageId = passage['id'] as int;
|
|
debugPrint('Recherche du passage ID: $passageId');
|
|
|
|
// Trouver le PassageModel original dans la liste
|
|
final PassageModel? passageModel =
|
|
_originalPassages.where((p) => p.id == passageId).firstOrNull;
|
|
|
|
if (passageModel == null) {
|
|
throw Exception('Passage original introuvable avec l\'ID: $passageId');
|
|
}
|
|
|
|
debugPrint('PassageModel original trouvé');
|
|
if (!mounted) {
|
|
debugPrint('Widget non monté, abandon');
|
|
return;
|
|
}
|
|
|
|
debugPrint('Ouverture du dialog...');
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => PassageFormDialog(
|
|
passage: passageModel,
|
|
title: 'Modifier le passage',
|
|
passageRepository: _passageRepository,
|
|
userRepository: _userRepository,
|
|
operationRepository: operationRepository,
|
|
onSuccess: () {
|
|
debugPrint('Dialog fermé avec succès');
|
|
// Recharger les données après modification
|
|
_loadPassages();
|
|
},
|
|
),
|
|
);
|
|
debugPrint('=== FIN _openPassageEditDialog ===');
|
|
} catch (e, stackTrace) {
|
|
debugPrint('=== ERREUR _openPassageEditDialog ===');
|
|
debugPrint('Erreur: $e');
|
|
debugPrint('StackTrace: $stackTrace');
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur lors de l\'ouverture du formulaire: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
Widget _buildDetailRow(String label, String value) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(
|
|
width: 150,
|
|
child: Text(
|
|
'$label :',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(value),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHistoryItem(DateTime date, String user, String action) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${date.day}/${date.month}/${date.year} à ${date.hour}h${date.minute.toString().padLeft(2, '0')}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
|
),
|
|
Text('$user - $action'),
|
|
const Divider(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Construction des filtres supplémentaires
|
|
Widget _buildAdditionalFilters(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final size = MediaQuery.of(context).size;
|
|
final isDesktop = size.width > 900;
|
|
|
|
return Card(
|
|
elevation: 2,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
color: Colors.white, // Fond opaque
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Filtres avancés',
|
|
style: theme.textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Champ de recherche
|
|
_buildSearchField(theme),
|
|
const SizedBox(height: 16),
|
|
|
|
// Disposition des filtres en fonction de la taille de l'écran
|
|
isDesktop
|
|
? Column(
|
|
children: [
|
|
// Première ligne : Secteur, Utilisateur, Période
|
|
Row(
|
|
children: [
|
|
// Filtre par secteur
|
|
Expanded(
|
|
child: _buildSectorFilter(theme, _sectors),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Filtre par membre
|
|
Expanded(
|
|
child: _buildMembreFilter(theme, _membres),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Filtre par période
|
|
Expanded(
|
|
child: _buildPeriodFilter(theme),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Deuxième ligne : Type de passage, Mode de règlement
|
|
Row(
|
|
children: [
|
|
// Filtre par type de passage
|
|
Expanded(
|
|
child: _buildTypeFilter(theme),
|
|
),
|
|
const SizedBox(width: 16),
|
|
|
|
// Filtre par mode de règlement
|
|
Expanded(
|
|
child: _buildPaymentFilter(theme),
|
|
),
|
|
// Espacement pour équilibrer avec la ligne du dessus (3 colonnes)
|
|
const Expanded(child: SizedBox()),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
children: [
|
|
// Filtre par secteur
|
|
_buildSectorFilter(theme, _sectors),
|
|
const SizedBox(height: 16),
|
|
|
|
// Filtre par membre
|
|
_buildMembreFilter(theme, _membres),
|
|
const SizedBox(height: 16),
|
|
|
|
// Filtre par période
|
|
_buildPeriodFilter(theme),
|
|
const SizedBox(height: 16),
|
|
|
|
// Filtre par type de passage
|
|
_buildTypeFilter(theme),
|
|
const SizedBox(height: 16),
|
|
|
|
// Filtre par mode de règlement
|
|
_buildPaymentFilter(theme),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Construction du filtre par secteur
|
|
Widget _buildSectorFilter(ThemeData theme, List<SectorModel> sectors) {
|
|
// Vérifier si la liste des secteurs est vide ou si selectedSector n'est pas dans la liste
|
|
bool isSelectedSectorValid = selectedSector == 'Tous' ||
|
|
sectors.any((s) => s.libelle == selectedSector);
|
|
|
|
// Si selectedSector n'est pas valide, le réinitialiser à 'Tous'
|
|
if (!isSelectedSectorValid) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
selectedSector = 'Tous';
|
|
selectedSectorId = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Secteur',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: isSelectedSectorValid ? selectedSector : 'Tous',
|
|
isExpanded: true,
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
value: 'Tous',
|
|
child: Text('Tous les secteurs'),
|
|
),
|
|
...sectors.map((sector) {
|
|
final String libelle = sector.libelle.isNotEmpty
|
|
? sector.libelle
|
|
: 'Secteur ${sector.id}';
|
|
return DropdownMenuItem<String>(
|
|
value: libelle,
|
|
child: Text(
|
|
libelle,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
onChanged: (String? value) {
|
|
if (value != null) {
|
|
if (value == 'Tous') {
|
|
_updateSectorFilter('Tous', null);
|
|
} else {
|
|
try {
|
|
// Trouver le secteur correspondant
|
|
final sector = sectors.firstWhere(
|
|
(s) => s.libelle == value,
|
|
orElse: () => sectors.isNotEmpty
|
|
? sectors.first
|
|
: throw Exception('Liste de secteurs vide'),
|
|
);
|
|
// Convertir sector.id en int? si nécessaire
|
|
_updateSectorFilter(value, sector.id);
|
|
} catch (e) {
|
|
debugPrint('Erreur lors de la sélection du secteur: $e');
|
|
_updateSectorFilter('Tous', null);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Construction du filtre par membre
|
|
Widget _buildMembreFilter(ThemeData theme, List<MembreModel> membres) {
|
|
// Fonction pour formater le nom d'affichage d'un membre
|
|
String formatMembreDisplayName(MembreModel membre) {
|
|
final String firstName = membre.firstName ?? '';
|
|
final String name = membre.name ?? '';
|
|
final String sectName = membre.sectName ?? '';
|
|
|
|
// Construire le nom de base
|
|
String displayName = '';
|
|
if (firstName.isNotEmpty && name.isNotEmpty) {
|
|
displayName = '$firstName $name';
|
|
} else if (name.isNotEmpty) {
|
|
displayName = name;
|
|
} else if (firstName.isNotEmpty) {
|
|
displayName = firstName;
|
|
} else {
|
|
displayName = 'Membre inconnu';
|
|
}
|
|
|
|
// Ajouter le sectName entre parenthèses s'il existe
|
|
if (sectName.isNotEmpty) {
|
|
displayName = '$displayName ($sectName)';
|
|
}
|
|
|
|
return displayName;
|
|
}
|
|
|
|
// Trier les membres par nom de famille
|
|
final List<MembreModel> sortedMembres = [...membres];
|
|
sortedMembres.sort((a, b) {
|
|
final String nameA = a.name ?? '';
|
|
final String nameB = b.name ?? '';
|
|
return nameA.compareTo(nameB);
|
|
});
|
|
|
|
// Créer une map pour retrouver les membres par leur nom d'affichage
|
|
final Map<String, MembreModel> membreDisplayMap = {};
|
|
for (final membre in sortedMembres) {
|
|
final displayName = formatMembreDisplayName(membre);
|
|
membreDisplayMap[displayName] = membre;
|
|
}
|
|
|
|
// Vérifier si la liste des membres est vide ou si selectedUser n'est pas dans la liste
|
|
bool isSelectedUserValid =
|
|
selectedUser == 'Tous' || membreDisplayMap.containsKey(selectedUser);
|
|
|
|
// Si selectedUser n'est pas valide, le réinitialiser à 'Tous'
|
|
if (!isSelectedUserValid) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (mounted) {
|
|
setState(() {
|
|
selectedUser = 'Tous';
|
|
selectedUserId = null;
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Membre',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: isSelectedUserValid ? selectedUser : 'Tous',
|
|
isExpanded: true,
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
value: 'Tous',
|
|
child: Text('Tous les membres'),
|
|
),
|
|
...membreDisplayMap.entries.map((entry) {
|
|
final String displayName = entry.key;
|
|
return DropdownMenuItem<String>(
|
|
value: displayName,
|
|
child: Text(
|
|
displayName,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
onChanged: (String? value) {
|
|
if (value != null) {
|
|
if (value == 'Tous') {
|
|
_updateUserFilter('Tous', null);
|
|
} else {
|
|
try {
|
|
// Trouver le membre correspondant dans la map
|
|
final membre = membreDisplayMap[value];
|
|
if (membre != null) {
|
|
final int membreId = membre.id;
|
|
_updateUserFilter(value, membreId);
|
|
} else {
|
|
throw Exception('Membre non trouvé: $value');
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Erreur lors de la sélection du membre: $e');
|
|
_updateUserFilter('Tous', null);
|
|
}
|
|
}
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Construction du filtre par période
|
|
Widget _buildPeriodFilter(ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Période',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: selectedPeriod,
|
|
isExpanded: true,
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
items: const [
|
|
DropdownMenuItem<String>(
|
|
value: 'Tous',
|
|
child: Text('Toutes les périodes'),
|
|
),
|
|
DropdownMenuItem<String>(
|
|
value: 'Derniers 15 jours',
|
|
child: Text('Derniers 15 jours'),
|
|
),
|
|
DropdownMenuItem<String>(
|
|
value: 'Dernière semaine',
|
|
child: Text('Dernière semaine'),
|
|
),
|
|
DropdownMenuItem<String>(
|
|
value: 'Dernier mois',
|
|
child: Text('Dernier mois'),
|
|
),
|
|
],
|
|
onChanged: (String? value) {
|
|
if (value != null) {
|
|
_updatePeriodFilter(value);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
|
|
// Afficher la plage de dates sélectionnée si elle existe
|
|
if (selectedDateRange != null && selectedPeriod != 'Tous')
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.date_range,
|
|
size: 16,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Du ${selectedDateRange!.start.day}/${selectedDateRange!.start.month}/${selectedDateRange!.start.year} au ${selectedDateRange!.end.day}/${selectedDateRange!.end.month}/${selectedDateRange!.end.year}',
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Construction du champ de recherche
|
|
Widget _buildSearchField(ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Recherche',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Rechercher par adresse ou nom...',
|
|
prefixIcon: const Icon(Icons.search),
|
|
suffixIcon: _searchController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear),
|
|
onPressed: () {
|
|
setState(() {
|
|
_searchController.clear();
|
|
searchQuery = '';
|
|
});
|
|
},
|
|
)
|
|
: null,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
searchQuery = value;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Construction du filtre par type de passage
|
|
Widget _buildTypeFilter(ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Type de passage',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: selectedType,
|
|
isExpanded: true,
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
value: 'Tous',
|
|
child: Text('Tous les types'),
|
|
),
|
|
...AppKeys.typesPassages.entries.map((entry) {
|
|
return DropdownMenuItem<String>(
|
|
value: entry.key.toString(),
|
|
child: Text(
|
|
entry.value['titre'] as String,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
onChanged: (String? value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
selectedType = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Construction du filtre par mode de règlement
|
|
Widget _buildPaymentFilter(ThemeData theme) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Mode de règlement',
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.symmetric(horizontal: 12.0),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: theme.colorScheme.outline),
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
),
|
|
child: DropdownButtonHideUnderline(
|
|
child: DropdownButton<String>(
|
|
value: selectedPaymentMethod,
|
|
isExpanded: true,
|
|
icon: const Icon(Icons.arrow_drop_down),
|
|
items: [
|
|
const DropdownMenuItem<String>(
|
|
value: 'Tous',
|
|
child: Text('Tous les modes'),
|
|
),
|
|
...AppKeys.typesReglements.entries.map((entry) {
|
|
return DropdownMenuItem<String>(
|
|
value: entry.key.toString(),
|
|
child: Text(
|
|
entry.value['titre'] as String,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}),
|
|
],
|
|
onChanged: (String? value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
selectedPaymentMethod = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|