From 36a903c80ece701a69fb938075de5e782d145d9f Mon Sep 17 00:00:00 2001 From: dahoud <41957584+DahoudG@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:14:08 +0000 Subject: [PATCH] =?UTF-8?q?feat(members):=20swipe=20par=20r=C3=B4le=20+=20?= =?UTF-8?q?suppression=20SuperAdmin=20avec=20cascade=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swipe actions différenciées par rôle : - SuperAdmin : → Reset MDP, ← Affecter Org - OrgAdmin : → Reset MDP, ← lifecycle selon statut (Suspendre/Activer/Réactiver) (masqué si cible = ORGADMIN/SUPERADMIN — cohérent avec guard backend) - Autres rôles : → Reset MDP seulement Suppression compte (SuperAdmin uniquement) : - Nouveau callback onDeleteAccount dans MembersPage + MemberDetailPage - Bouton rouge 'Supprimer ce compte' dans action sheet (zone destructive) - Dialog de confirmation adaptatif dark/light avec badge admin si cible ORGADMIN - Bouton caché si compte déjà désactivé (actif=false) - Bannière 'Compte désactivé' visible sur page détail d'un compte soft-deleted - BlocListener MembreDeleted : SnackBar + maybePop() + reload liste - Bloc gère 409 Conflict (mono-admin) → MembresActionForbidden avec message backend Nouvelles signatures : - onLifecycleAction : (memberId, organisationId, action, motif) — inclut orgId pour permettre au SuperAdmin d'agir via l'org du membre lui-même - 'actif' et 'roleCode' exposés dans la map via _convertMembreToMap --- lib/features/members/bloc/membres_bloc.dart | 35 + lib/features/members/bloc/membres_state.dart | 6 + .../pages/members_page_connected.dart | 2226 ++++++++++++----- .../pages/members_page_wrapper.dart | 117 +- 4 files changed, 1661 insertions(+), 723 deletions(-) diff --git a/lib/features/members/bloc/membres_bloc.dart b/lib/features/members/bloc/membres_bloc.dart index 4e98abe..5f3d690 100644 --- a/lib/features/members/bloc/membres_bloc.dart +++ b/lib/features/members/bloc/membres_bloc.dart @@ -278,6 +278,14 @@ class MembresBloc extends Bloc { emit(MembreDeleted(event.id)); } on DioException catch (e) { if (e.type == DioExceptionType.cancel) return; + // 409 Conflict = mono-admin orphelinage détecté par le backend. + // Émettre MembresActionForbidden avec le message métier du backend. + if (e.response?.statusCode == 409 || e.response?.statusCode == 403) { + final msg = _extractErrorMessage(e) + ?? 'Suppression impossible : vérifiez que le membre n\'est pas le seul admin d\'une organisation.'; + emit(MembresActionForbidden(message: msg, error: e)); + return; + } emit(MembresNetworkError( message: _getNetworkErrorMessage(e), code: e.response?.statusCode.toString(), @@ -582,6 +590,18 @@ class MembresBloc extends Bloc { } } + /// Extrait le message d'erreur du corps JSON d'une réponse HTTP (champ "error" + /// ou "message"). Retourne null si non disponible. + String? _extractErrorMessage(DioException e) { + try { + final data = e.response?.data; + if (data is Map) { + return (data['message'] ?? data['error'])?.toString(); + } + } catch (_) {} + return null; + } + // ── Handlers cycle de vie des adhésions ────────────────────────────────── Future _onInviterMembre( @@ -629,6 +649,11 @@ class MembresBloc extends Bloc { )); } on DioException catch (e) { if (e.type == DioExceptionType.cancel) return; + if (e.response?.statusCode == 403) { + final msg = _extractErrorMessage(e) ?? 'Action non autorisée sur ce membre.'; + emit(MembresActionForbidden(message: msg, error: e)); + return; + } emit(MembresNetworkError( message: _getNetworkErrorMessage(e), code: e.response?.statusCode.toString(), @@ -656,6 +681,11 @@ class MembresBloc extends Bloc { )); } on DioException catch (e) { if (e.type == DioExceptionType.cancel) return; + if (e.response?.statusCode == 403) { + final msg = _extractErrorMessage(e) ?? 'Action non autorisée sur ce membre.'; + emit(MembresActionForbidden(message: msg, error: e)); + return; + } emit(MembresNetworkError( message: _getNetworkErrorMessage(e), code: e.response?.statusCode.toString(), @@ -683,6 +713,11 @@ class MembresBloc extends Bloc { )); } on DioException catch (e) { if (e.type == DioExceptionType.cancel) return; + if (e.response?.statusCode == 403) { + final msg = _extractErrorMessage(e) ?? 'Action non autorisée sur ce membre.'; + emit(MembresActionForbidden(message: msg, error: e)); + return; + } emit(MembresNetworkError( message: _getNetworkErrorMessage(e), code: e.response?.statusCode.toString(), diff --git a/lib/features/members/bloc/membres_state.dart b/lib/features/members/bloc/membres_state.dart index 4cd97b5..2156de1 100644 --- a/lib/features/members/bloc/membres_state.dart +++ b/lib/features/members/bloc/membres_state.dart @@ -248,6 +248,12 @@ class MembresNetworkError extends MembresError { }); } +/// Accès refusé (HTTP 403) sur une action lifecycle (radier, suspendre, archiver). +/// Distinct de MembresNetworkError pour permettre un message utilisateur ciblé. +class MembresActionForbidden extends MembresError { + const MembresActionForbidden({required super.message, super.code = '403', super.error}); +} + /// État d'erreur de validation class MembresValidationError extends MembresError { final Map validationErrors; diff --git a/lib/features/members/presentation/pages/members_page_connected.dart b/lib/features/members/presentation/pages/members_page_connected.dart index 629fd8f..6766d5d 100644 --- a/lib/features/members/presentation/pages/members_page_connected.dart +++ b/lib/features/members/presentation/pages/members_page_connected.dart @@ -1,12 +1,23 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; -import '../../../../shared/design_system/unionflow_design_v2.dart'; +import '../../../../shared/design_system/unionflow_design_system.dart'; import '../../../../shared/design_system/components/uf_app_bar.dart'; import '../../../../core/constants/app_constants.dart'; import '../../../../features/organizations/domain/repositories/organization_repository.dart'; -/// Annuaire des Membres - Design UnionFlow + +/// Annuaire des Membres — Design UnionFlow v2 +/// +/// Fonctionnalités : +/// - Identité violet #7616E8 +/// - Header KPI 4 métriques (Total, % Actifs, En attente, Cotisations) +/// - Recherche inline collapsible + filtres chips +/// - Scroll infini (append pages au fur et à mesure) +/// - Mode grille / liste toggle +/// - Swipe gauche → Reset MDP | Swipe droit → Affecter org +/// - Long press → sélection multiple (Exporter, Affecter org) +/// - Navigation page complète au lieu d'une bottom sheet class MembersPageWithDataAndPagination extends StatefulWidget { final List> members; final int totalCount; @@ -18,14 +29,12 @@ class MembersPageWithDataAndPagination extends StatefulWidget { final VoidCallback? onAddMember; /// null = SUPER_ADMIN (vue globale, affiche l'organisation sur chaque carte) final String? organisationId; - /// Callback déclenché quand l'admin active un membre en attente final void Function(String memberId)? onActivateMember; - /// Callback déclenché quand l'admin réinitialise le mot de passe d'un membre final void Function(String memberId)? onResetPassword; - /// Callback déclenché quand le superadmin affecte un membre à une organisation final void Function(String memberId, String organisationId)? onAffecterOrganisation; - /// Callback pour les actions de cycle de vie adhésion (admin org) - final void Function(String memberId, String action, String? motif)? onLifecycleAction; + final void Function(String memberId, String organisationId, String action, String? motif)? onLifecycleAction; + /// Suppression définitive du compte (SuperAdmin uniquement — backend @RolesAllowed ADMIN/SUPER_ADMIN). + final void Function(String memberId)? onDeleteAccount; const MembersPageWithDataAndPagination({ super.key, @@ -42,114 +51,303 @@ class MembersPageWithDataAndPagination extends StatefulWidget { this.onResetPassword, this.onAffecterOrganisation, this.onLifecycleAction, + this.onDeleteAccount, }); @override - State createState() => _MembersPageWithDataAndPaginationState(); + State createState() => _MembersPageState(); } -class _MembersPageWithDataAndPaginationState extends State { +class _MembersPageState extends State { final TextEditingController _searchController = TextEditingController(); + final ScrollController _scrollController = ScrollController(); String _searchQuery = ''; String _filterStatus = 'Tous'; Timer? _searchDebounce; - // Organisations pour le picker d'affectation (superadmin) + bool _isSearchExpanded = false; + bool _isGridView = false; + bool _isSelectionMode = false; + final Set _selectedIds = {}; + bool _isLoadingMore = false; + + List> _accumulatedMembers = []; List> _organisationsPicker = []; bool get _isSuperAdmin => widget.organisationId == null; + @override + void initState() { + super.initState(); + _accumulatedMembers = List.from(widget.members); + _scrollController.addListener(_onScroll); + } + + @override + void didUpdateWidget(covariant MembersPageWithDataAndPagination old) { + super.didUpdateWidget(old); + if (old.members != widget.members) { + setState(() { + if (widget.currentPage == 0) { + _accumulatedMembers = List.from(widget.members); + } else { + // Append page suivante (dédoublonnage par id) + final existingIds = _accumulatedMembers.map((m) => m['id'] as String? ?? '').toSet(); + final newItems = widget.members.where((m) => !existingIds.contains(m['id'] as String? ?? '')).toList(); + _accumulatedMembers.addAll(newItems); + } + _isLoadingMore = false; + }); + } + } + @override void dispose() { _searchDebounce?.cancel(); _searchController.dispose(); + _scrollController.dispose(); super.dispose(); } + void _onScroll() { + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent - 200) { + if (!_isLoadingMore && widget.currentPage < widget.totalPages - 1) { + setState(() => _isLoadingMore = true); + widget.onPageChanged(widget.currentPage + 1, _searchQuery.isEmpty ? null : _searchQuery); + } + } + } + + void _clearSearch() { + _searchDebounce?.cancel(); + _searchController.clear(); + setState(() => _searchQuery = ''); + widget.onSearch?.call(null); + } + + void _toggleSelection(String id) { + setState(() { + if (_selectedIds.contains(id)) { + _selectedIds.remove(id); + if (_selectedIds.isEmpty) _isSelectionMode = false; + } else { + _selectedIds.add(id); + } + }); + } + + void _exitSelectionMode() { + setState(() { + _isSelectionMode = false; + _selectedIds.clear(); + }); + } + + List> get _filteredMembers { + return _accumulatedMembers.where((m) { + final matchesSearch = _searchQuery.isEmpty || + (m['name'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) || + (m['email'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) || + (m['numeroMembre'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()); + final matchesStatus = _filterStatus == 'Tous' || m['status'] == _filterStatus; + return matchesSearch && matchesStatus; + }).toList(); + } + + // ═══════════════════════════════════════════════════════════════════════════ + // Build + // ═══════════════════════════════════════════════════════════════════════════ + @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: UnionFlowColors.background, - appBar: UFAppBar( - title: 'Annuaire Membres', - backgroundColor: UnionFlowColors.surface, - foregroundColor: UnionFlowColors.textPrimary, - actions: [ - if (widget.onAddMember != null) - IconButton( - icon: const Icon(Icons.person_add_outlined), - color: UnionFlowColors.unionGreen, - onPressed: widget.onAddMember, - tooltip: 'Ajouter un membre', - ), - const SizedBox(width: 8), - ], - ), + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildMainAppBar(), body: Column( children: [ - _buildHeader(), - _buildSearchAndFilters(), - Expanded(child: _buildMembersList()), - if (widget.totalPages > 1) _buildPagination(), + _buildKpiHeader(context), + AnimatedCrossFade( + duration: const Duration(milliseconds: 220), + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + sizeCurve: Curves.easeInOut, + crossFadeState: _isSearchExpanded ? CrossFadeState.showFirst : CrossFadeState.showSecond, + firstChild: _buildSearchPanel(context), + secondChild: const SizedBox(width: double.infinity, height: 0), + ), + Expanded(child: _buildContent(context)), ], ), ); } - // ── Header ──────────────────────────────────────────────────────────────── + // ─── AppBar principale ──────────────────────────────────────────────────── - Widget _buildHeader() { - final pageMembers = widget.members; - final activeCount = pageMembers.where((m) => m['status'] == 'Actif').length; - final pendingCount = pageMembers.where((m) => m['status'] == 'En attente').length; + PreferredSizeWidget _buildMainAppBar() { + final hasFilter = _filterStatus != 'Tous' || _searchQuery.isNotEmpty; + return UFAppBar( + title: 'Annuaire Membres', + moduleGradient: ModuleColors.membresGradient, + actions: [ + Stack( + children: [ + IconButton( + icon: Icon(_isSearchExpanded ? Icons.search_off : Icons.search), + onPressed: () => setState(() => _isSearchExpanded = !_isSearchExpanded), + tooltip: 'Recherche & filtres', + ), + if (hasFilter) + Positioned( + right: 10, + top: 10, + child: Container( + width: 7, + height: 7, + decoration: BoxDecoration(color: AppColors.warning, shape: BoxShape.circle), + ), + ), + ], + ), + IconButton( + icon: Icon(_isGridView ? Icons.view_list_outlined : Icons.grid_view_outlined), + onPressed: () => setState(() => _isGridView = !_isGridView), + tooltip: _isGridView ? 'Vue liste' : 'Vue grille', + ), + if (widget.onAddMember != null) + IconButton( + icon: const Icon(Icons.person_add_outlined), + onPressed: widget.onAddMember, + tooltip: 'Ajouter un membre', + ), + const SizedBox(width: 4), + ], + ); + } + + // ─── AppBar sélection multiple ──────────────────────────────────────────── + + PreferredSizeWidget _buildSelectionAppBar() { + return AppBar( + backgroundColor: ModuleColors.membres, + foregroundColor: AppColors.onPrimary, + elevation: 0, + iconTheme: const IconThemeData(color: AppColors.onPrimary), + actionsIconTheme: const IconThemeData(color: AppColors.onPrimary), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: _exitSelectionMode, + ), + title: Text( + '${_selectedIds.length} sélectionné(s)', + style: const TextStyle(color: AppColors.onPrimary, fontWeight: FontWeight.w600), + ), + actions: [ + if (_selectedIds.isNotEmpty && widget.onAffecterOrganisation != null && _isSuperAdmin) + IconButton( + icon: const Icon(Icons.business_outlined), + tooltip: 'Affecter à une organisation', + onPressed: _showBulkAffecterDialog, + ), + if (_selectedIds.isNotEmpty) + IconButton( + icon: const Icon(Icons.download_outlined), + tooltip: 'Exporter la sélection', + onPressed: _bulkExporter, + ), + const SizedBox(width: 4), + ], + ); + } + + // ─── Header KPI ──────────────────────────────────────────────────────────── + + Widget _buildKpiHeader(BuildContext context) { + final total = widget.totalCount; + final displayed = _accumulatedMembers.length; + final activeCount = _accumulatedMembers.where((m) => m['status'] == 'Actif').length; + final pendingCount = _accumulatedMembers.where((m) => m['status'] == 'En attente').length; + final cotisOkCount = _accumulatedMembers.where((m) => m['cotisationAJour'] == true).length; + final activePercent = displayed > 0 ? activeCount / displayed : 0.0; + final cotisPercent = displayed > 0 ? cotisOkCount / displayed : 0.0; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), decoration: BoxDecoration( - color: UnionFlowColors.surface, - border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), + color: Theme.of(context).colorScheme.surface, + border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 1)), ), child: Row( children: [ - Expanded(child: _buildStatBadge('Total', widget.totalCount.toString(), UnionFlowColors.unionGreen, subtitle: 'global')), - const SizedBox(width: 8), - Expanded(child: _buildStatBadge('Actifs', activeCount.toString(), UnionFlowColors.success, subtitle: 'cette page')), - const SizedBox(width: 8), - Expanded(child: _buildStatBadge('Attente', pendingCount.toString(), UnionFlowColors.warning, subtitle: 'cette page')), + _kpiTile(context, 'Total', total.toString(), ModuleColors.membres, Icons.people_outline), + _kpiSeparator(context), + _kpiTileProgress(context, 'Actifs', '${(activePercent * 100).toStringAsFixed(0)}%', AppColors.success, activePercent), + _kpiSeparator(context), + _kpiTile(context, 'Attente', pendingCount.toString(), AppColors.warning, Icons.schedule_outlined), + _kpiSeparator(context), + _kpiTileProgress( + context, + 'Cotisations', + '${(cotisPercent * 100).toStringAsFixed(0)}%', + cotisPercent >= 0.7 ? AppColors.success : AppColors.error, + cotisPercent, + ), ], ), ); } - Widget _buildStatBadge(String label, String value, Color color, {String? subtitle}) { - return Container( - padding: const EdgeInsets.symmetric(vertical: 7, horizontal: 6), - decoration: BoxDecoration( - color: color.withOpacity(0.08), - borderRadius: BorderRadius.circular(8), - border: Border.all(color: color.withOpacity(0.25), width: 1), - ), + Widget _kpiTile(BuildContext context, String label, String value, Color color, IconData icon) { + return Expanded( child: Column( + mainAxisSize: MainAxisSize.min, children: [ - Text(value, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w800, color: color)), - Text(label, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color)), - if (subtitle != null) - Text(subtitle, style: TextStyle(fontSize: 9, color: color.withOpacity(0.6))), + Icon(icon, size: 14, color: color), + const SizedBox(height: 1), + Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: color)), + Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: color.withOpacity(0.75)), textAlign: TextAlign.center), ], ), ); } - // ── Recherche + Filtres ──────────────────────────────────────────────────── + Widget _kpiTileProgress(BuildContext context, String label, String value, Color color, double progress) { + return Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: color)), + const SizedBox(height: 3), + ClipRRect( + borderRadius: BorderRadius.circular(2), + child: LinearProgressIndicator( + value: progress, + backgroundColor: color.withOpacity(0.15), + valueColor: AlwaysStoppedAnimation(color), + minHeight: 4, + ), + ), + const SizedBox(height: 2), + Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: color.withOpacity(0.75)), textAlign: TextAlign.center), + ], + ), + ); + } - Widget _buildSearchAndFilters() { + Widget _kpiSeparator(BuildContext context) => + Container(width: 1, height: 42, color: Theme.of(context).colorScheme.outlineVariant); + + // ─── Panneau recherche collapsible ──────────────────────────────────────── + + Widget _buildSearchPanel(BuildContext context) { return Container( - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), decoration: BoxDecoration( - color: UnionFlowColors.surface, - border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), + color: Theme.of(context).colorScheme.surface, + border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 1)), ), child: Column( + mainAxisSize: MainAxisSize.min, children: [ TextField( controller: _searchController, @@ -160,42 +358,1069 @@ class _MembersPageWithDataAndPaginationState extends State _searchQuery = ''); - widget.onSearch?.call(null); - }, + icon: const Icon(Icons.clear, size: 18), + onPressed: _clearSearch, ) : null, - contentPadding: const EdgeInsets.symmetric(vertical: 11, horizontal: 14), - border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.3))), - enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: UnionFlowColors.border.withOpacity(0.3))), - focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: UnionFlowColors.unionGreen, width: 1.5)), + contentPadding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)), + enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide(color: Theme.of(context).colorScheme.outline)), + focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(12)), borderSide: BorderSide(color: ModuleColors.membres, width: 1.5)), filled: true, - fillColor: UnionFlowColors.surfaceVariant.withOpacity(0.3), + fillColor: Theme.of(context).colorScheme.surfaceContainerHighest, ), ), - const SizedBox(height: 10), + const SizedBox(height: 8), SingleChildScrollView( scrollDirection: Axis.horizontal, child: Row( + children: ['Tous', 'Actif', 'Inactif', 'En attente', 'Suspendu'] + .map((f) => Padding( + padding: const EdgeInsets.only(right: 8), + child: _buildFilterChip(context, f), + )) + .toList(), + ), + ), + ], + ), + ); + } + + Widget _buildFilterChip(BuildContext context, String label) { + final isSelected = _filterStatus == label; + return GestureDetector( + onTap: () => setState(() => _filterStatus = label), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isSelected ? ModuleColors.membres : Colors.transparent, + borderRadius: BorderRadius.circular(20), + border: Border.all(color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outline, width: 1), + ), + child: Text( + label, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: isSelected ? AppColors.onPrimary : Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + // ─── Contenu principal ──────────────────────────────────────────────────── + + Widget _buildContent(BuildContext context) { + final filtered = _filteredMembers; + + return RefreshIndicator( + onRefresh: () async { + setState(() => _accumulatedMembers.clear()); + widget.onRefresh(); + }, + color: ModuleColors.membres, + child: filtered.isEmpty + ? SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: _buildEmptyState(context), + ) + : _isGridView + ? _buildGridView(context, filtered) + : _buildListView(context, filtered), + ); + } + + Widget _buildListView(BuildContext context, List> members) { + return ListView.separated( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + itemCount: members.length + (_isLoadingMore ? 1 : 0), + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, i) { + if (i == members.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator(color: ModuleColors.membres), + ), + ); + } + return _buildSwipeableCard(context, members[i]); + }, + ); + } + + Widget _buildGridView(BuildContext context, List> members) { + return GridView.builder( + controller: _scrollController, + padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 0.82, + crossAxisSpacing: 10, + mainAxisSpacing: 10, + ), + itemCount: members.length + (_isLoadingMore ? 1 : 0), + itemBuilder: (context, i) { + if (i == members.length) { + return const Center(child: CircularProgressIndicator(color: ModuleColors.membres)); + } + return _buildGridCard(context, members[i]); + }, + ); + } + + // ─── Carte liste avec swipe ──────────────────────────────────────────────── + + /// Détermine l'action swipe gauche pour l'orgAdmin selon le statut du membre. + /// Retourne null si aucune action n'est disponible (rôle admin, statut final, etc.). + _SwipeAction? _getLeftSwipeAction(Map member) { + // SuperAdmin : affecter org (géré séparément) + if (_isSuperAdmin) return null; + + // Pas de lifecycle disponible + if (widget.onLifecycleAction == null) return null; + + // Pas d'action sur un admin/superadmin + final roleCode = (member['roleCode'] as String? ?? '').toUpperCase(); + if (roleCode == 'ORGADMIN' || roleCode == 'SUPERADMIN' || + roleCode == 'ADMIN_ORGANISATION' || roleCode == 'SUPER_ADMIN') { + return null; + } + + final statut = (member['statutMembre'] as String? ?? + member['status'] as String? ?? '') + .toUpperCase(); + + switch (statut) { + case 'EN_ATTENTE_VALIDATION': + case 'EN_ATTENTE': + case 'INVITE': + return const _SwipeAction( + action: 'activer', + icon: Icons.check_circle_outline, + label: 'Activer', + color: AppColors.success, + ); + case 'ACTIF': + return const _SwipeAction( + action: 'suspendre', + icon: Icons.pause_circle_outline, + label: 'Suspendre', + color: AppColors.warning, + ); + case 'SUSPENDU': + return const _SwipeAction( + action: 'activer', + icon: Icons.play_circle_outline, + label: 'Réactiver', + color: AppColors.info, + ); + default: + // RADIE, ARCHIVE, etc. → pas d'action rapide + return null; + } + } + + Widget _buildSwipeableCard(BuildContext context, Map member) { + final id = member['id'] as String? ?? ''; + if (_isSelectionMode) { + return _buildListCard(context, member, isSelected: _selectedIds.contains(id)); + } + + // Swipe droite → Reset MDP (tous rôles admin si callback fourni) + final canSwipeRight = widget.onResetPassword != null; + + // Swipe gauche → SuperAdmin: affecter org | OrgAdmin: lifecycle selon statut + final bool canSwipeLeftSuperAdmin = + _isSuperAdmin && widget.onAffecterOrganisation != null; + final _SwipeAction? leftAction = _getLeftSwipeAction(member); + final bool canSwipeLeft = canSwipeLeftSuperAdmin || leftAction != null; + + if (!canSwipeRight && !canSwipeLeft) { + return _buildListCard(context, member); + } + + // Background droite (Reset MDP) — toujours warning/orange + final rightBg = _swipeActionBackground( + isLeft: true, + icon: Icons.lock_reset_outlined, + label: 'Reset\nMDP', + color: AppColors.warning, + ); + + // Background gauche — contextuel selon rôle + Widget leftBg; + if (canSwipeLeftSuperAdmin) { + leftBg = _swipeActionBackground( + isLeft: false, + icon: Icons.business_outlined, + label: 'Affecter\nOrg', + color: ModuleColors.membres, + ); + } else if (leftAction != null) { + leftBg = _swipeActionBackground( + isLeft: false, + icon: leftAction.icon, + label: leftAction.label, + color: leftAction.color, + ); + } else { + leftBg = const SizedBox.shrink(); + } + + return Dismissible( + key: Key('swipe_$id'), + direction: canSwipeRight && canSwipeLeft + ? DismissDirection.horizontal + : canSwipeRight + ? DismissDirection.startToEnd + : DismissDirection.endToStart, + confirmDismiss: (dir) async { + if (dir == DismissDirection.startToEnd && canSwipeRight) { + widget.onResetPassword!(id); + } else if (dir == DismissDirection.endToStart) { + if (canSwipeLeftSuperAdmin) { + _showAffecterOrganisationDialog(context, member); + } else if (leftAction != null) { + final orgId = member['organisationId'] as String? ?? ''; + if (orgId.isNotEmpty) { + widget.onLifecycleAction!(id, orgId, leftAction.action, null); + } + } + } + return false; // la carte ne disparaît pas + }, + background: canSwipeRight ? rightBg : const SizedBox.shrink(), + secondaryBackground: canSwipeLeft ? leftBg : const SizedBox.shrink(), + child: _buildListCard(context, member), + ); + } + + Widget _swipeActionBackground({ + required bool isLeft, + required IconData icon, + required String label, + required Color color, + }) { + return Container( + margin: const EdgeInsets.symmetric(vertical: 1), + decoration: BoxDecoration( + color: color.withOpacity(0.13), + borderRadius: BorderRadius.circular(12), + ), + alignment: isLeft ? Alignment.centerLeft : Alignment.centerRight, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: color, size: 22), + const SizedBox(height: 4), + Text( + label, + style: TextStyle( + fontSize: 10, fontWeight: FontWeight.w700, color: color), + textAlign: TextAlign.center, + ), + ], + ), + ); + } + + // ─── Carte liste ────────────────────────────────────────────────────────── + + Widget _buildListCard(BuildContext context, Map member, {bool isSelected = false}) { + final id = member['id'] as String? ?? ''; + final status = member['status'] as String? ?? '?'; + final orgName = member['organisationNom'] as String?; + final numero = member['numeroMembre'] as String?; + final cotisAJour = member['cotisationAJour'] as bool? ?? false; + final statutKyc = member['statutKyc'] as String?; + + return GestureDetector( + onTap: () { + if (_isSelectionMode) { + _toggleSelection(id); + } else { + _navigateToDetail(context, member); + } + }, + onLongPress: () { + if (!_isSelectionMode) { + setState(() { + _isSelectionMode = true; + _selectedIds.add(id); + }); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: isSelected ? ModuleColors.membres.withOpacity(0.08) : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outlineVariant, + width: isSelected ? 1.5 : 1, + ), + ), + child: Row( + children: [ + // Checkbox ou Avatar + if (_isSelectionMode) + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? ModuleColors.membres : Colors.transparent, + border: Border.all(color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outline, width: 2), + ), + child: isSelected ? const Icon(Icons.check, size: 18, color: AppColors.onPrimary) : null, + ) + else + _buildAvatarWidget(member, 36), + const SizedBox(width: 12), + // Infos + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + member['name'] as String? ?? 'Inconnu', + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), + overflow: TextOverflow.ellipsis, + ), + ), + if (numero != null && numero.isNotEmpty) ...[ + const SizedBox(width: 6), + Text( + numero, + style: TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ], + ), + const SizedBox(height: 2), + Text( + member['role'] as String? ?? 'Membre', + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + if (_isSuperAdmin && orgName != null && orgName.isNotEmpty) ...[ + const SizedBox(height: 3), + Row( + children: [ + const Icon(Icons.business_outlined, size: 11, color: ModuleColors.membres), + const SizedBox(width: 4), + Expanded( + child: Text( + orgName, + style: const TextStyle(fontSize: 11, color: ModuleColors.membres, fontWeight: FontWeight.w500), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ], + // Indicateurs KYC + Cotisation + const SizedBox(height: 5), + Row( + children: [ + _miniIndicator( + cotisAJour ? Icons.payments_outlined : Icons.money_off_outlined, + cotisAJour ? AppColors.success : AppColors.error, + cotisAJour ? 'Cotis. OK' : 'Cotis. NOK', + ), + if (statutKyc != null) ...[ + const SizedBox(width: 5), + _kycMiniIndicator(statutKyc), + ], + ], + ), + ], + ), + ), + const SizedBox(width: 8), + _statusBadge(status), + ], + ), + ), + ); + } + + Widget _miniIndicator(IconData icon, Color color, String label) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(6), + border: Border.all(color: color.withOpacity(0.3), width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 3), + Text(label, style: TextStyle(fontSize: 9, fontWeight: FontWeight.w600, color: color)), + ], + ), + ); + } + + Widget _kycMiniIndicator(String statutKyc) { + final (Color color, IconData icon) = switch (statutKyc) { + 'VERIFIE' => (AppColors.success, Icons.verified_user_outlined), + 'EN_COURS' => (AppColors.warning, Icons.pending_outlined), + 'REJETE' => (AppColors.error, Icons.gpp_bad_outlined), + _ => (AppColors.textTertiary, Icons.help_outline), + }; + return _miniIndicator(icon, color, 'KYC'); + } + + // ─── Carte grille ───────────────────────────────────────────────────────── + + Widget _buildGridCard(BuildContext context, Map member) { + final id = member['id'] as String? ?? ''; + final status = member['status'] as String? ?? '?'; + final cotisAJour = member['cotisationAJour'] as bool? ?? false; + final statutKyc = member['statutKyc'] as String?; + final isSelected = _selectedIds.contains(id); + + return GestureDetector( + onTap: () => _isSelectionMode ? _toggleSelection(id) : _navigateToDetail(context, member), + onLongPress: () { + if (!_isSelectionMode) { + setState(() { _isSelectionMode = true; _selectedIds.add(id); }); + } + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + decoration: BoxDecoration( + color: isSelected ? ModuleColors.membres.withOpacity(0.08) : Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: isSelected ? ModuleColors.membres : Theme.of(context).colorScheme.outlineVariant, + width: isSelected ? 1.5 : 1, + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + _buildAvatarWidget(member, 48), + if (isSelected) + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 18, + height: 18, + decoration: const BoxDecoration(color: ModuleColors.membres, shape: BoxShape.circle), + child: const Icon(Icons.check, size: 12, color: AppColors.onPrimary), + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + member['name'] as String? ?? 'Inconnu', + style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface), + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 5), + _statusBadge(status), + const SizedBox(height: 6), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(cotisAJour ? Icons.payments_outlined : Icons.money_off_outlined, size: 14, color: cotisAJour ? AppColors.success : AppColors.error), + if (statutKyc != null) ...[ + const SizedBox(width: 6), + Icon( + switch (statutKyc) { + 'VERIFIE' => Icons.verified_user_outlined, + 'EN_COURS' => Icons.pending_outlined, + _ => Icons.gpp_bad_outlined, + }, + size: 14, + color: switch (statutKyc) { + 'VERIFIE' => AppColors.success, + 'EN_COURS' => AppColors.warning, + _ => AppColors.error, + }, + ), + ], + ], + ), + ], + ), + ), + ), + ); + } + + // ─── Avatar violet ──────────────────────────────────────────────────────── + + Widget _buildAvatarWidget(Map member, double size) { + return Container( + width: size, + height: size, + decoration: const BoxDecoration( + gradient: LinearGradient(colors: [ModuleColors.membresDark, ModuleColors.membres], begin: Alignment.topLeft, end: Alignment.bottomRight), + shape: BoxShape.circle, + ), + alignment: Alignment.center, + child: Text( + member['initiales'] as String? ?? '??', + style: TextStyle(color: AppColors.onPrimary, fontWeight: FontWeight.w700, fontSize: size * 0.36), + ), + ); + } + + // ─── Badge statut ───────────────────────────────────────────────────────── + + Widget _statusBadge(String status) { + final (Color color, IconData icon) = switch (status) { + 'Actif' => (AppColors.success, Icons.check_circle_outline), + 'Inactif' => (AppColors.error, Icons.cancel_outlined), + 'En attente' => (AppColors.warning, Icons.schedule_outlined), + 'Suspendu' => (AppColors.textTertiary, Icons.block_outlined), + _ => (AppColors.textTertiary, Icons.help_outline), + }; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.3), width: 1), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 10, color: color), + const SizedBox(width: 3), + Text(status, style: TextStyle(fontSize: 10, fontWeight: FontWeight.w600, color: color)), + ], + ), + ); + } + + // ─── Empty state enrichi ────────────────────────────────────────────────── + + Widget _buildEmptyState(BuildContext context) { + final hasFilters = _filterStatus != 'Tous' || _searchQuery.isNotEmpty; + return Center( + child: Padding( + padding: const EdgeInsets.all(40), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration(color: ModuleColors.membres.withOpacity(0.08), shape: BoxShape.circle), + child: Icon( + hasFilters ? Icons.filter_list_off : Icons.people_outline, + size: 44, + color: ModuleColors.membres, + ), + ), + const SizedBox(height: 20), + Text( + hasFilters ? 'Aucun résultat' : 'Aucun membre', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurface), + ), + const SizedBox(height: 8), + Text( + hasFilters + ? 'Aucun membre ne correspond à votre recherche ou filtre.' + : 'Aucun membre enregistré pour le moment.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13, height: 1.5, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + const SizedBox(height: 24), + if (hasFilters) + FilledButton.icon( + onPressed: () { + setState(() { _filterStatus = 'Tous'; _searchQuery = ''; _searchController.clear(); }); + widget.onSearch?.call(null); + }, + icon: const Icon(Icons.filter_list_off, size: 18), + label: const Text('Effacer les filtres'), + style: FilledButton.styleFrom(backgroundColor: ModuleColors.membres), + ) + else if (widget.onAddMember != null) + FilledButton.icon( + onPressed: widget.onAddMember, + icon: const Icon(Icons.person_add_outlined, size: 18), + label: const Text('Ajouter un membre'), + style: FilledButton.styleFrom(backgroundColor: ModuleColors.membres), + ), + ], + ), + ), + ); + } + + // ─── Navigation vers page de détail ────────────────────────────────────── + + void _navigateToDetail(BuildContext context, Map member) { + Navigator.of(context).push(MaterialPageRoute( + builder: (_) => MemberDetailPage( + member: member, + isSuperAdmin: _isSuperAdmin, + onActivateMember: widget.onActivateMember, + onResetPassword: widget.onResetPassword, + onAffecterOrganisation: widget.onAffecterOrganisation, + onLifecycleAction: widget.onLifecycleAction, + onDeleteAccount: widget.onDeleteAccount, + organisationsPicker: _organisationsPicker, + onOrganisationsLoaded: (orgs) => setState(() => _organisationsPicker = orgs), + ), + )); + } + + // ─── Bulk actions ───────────────────────────────────────────────────────── + + Future _showBulkAffecterDialog() async { + await _loadOrganisationsPicker(); + if (!mounted) return; + String? selectedOrgId; + await showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( + title: Text('Affecter ${_selectedIds.length} membre(s)'), + content: _organisationsPicker.isEmpty + ? const Text('Aucune organisation disponible.') + : DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Organisation'), + items: _organisationsPicker + .map((o) => DropdownMenuItem(value: o['id'], child: Text(o['nom']!))) + .toList(), + onChanged: (v) => set(() => selectedOrgId = v), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary), + onPressed: selectedOrgId == null + ? null + : () { + Navigator.pop(ctx); + for (final id in _selectedIds) { + widget.onAffecterOrganisation!(id, selectedOrgId!); + } + _exitSelectionMode(); + }, + child: const Text('Confirmer'), + ), + ], + ), + ), + ); + } + + void _bulkExporter() { + final count = _selectedIds.length; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Export de $count membre(s) — fonctionnalité à venir'), + backgroundColor: ModuleColors.membres, + duration: const Duration(seconds: 3), + ), + ); + _exitSelectionMode(); + } + + Future _showAffecterOrganisationDialog(BuildContext ctx, Map member) async { + await _loadOrganisationsPicker(); + if (!mounted) return; + String? selectedOrgId; + await showDialog( + context: ctx, + builder: (dialogCtx) => StatefulBuilder( + builder: (dialogCtx, set) => AlertDialog( + title: const Text('Affecter à une organisation'), + content: _organisationsPicker.isEmpty + ? const Text('Aucune organisation disponible.') + : DropdownButtonFormField( + decoration: const InputDecoration(labelText: 'Organisation'), + items: _organisationsPicker + .map((o) => DropdownMenuItem(value: o['id'], child: Text(o['nom']!))) + .toList(), + onChanged: (v) => set(() => selectedOrgId = v), + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogCtx), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary), + onPressed: selectedOrgId == null + ? null + : () { + Navigator.pop(dialogCtx); + widget.onAffecterOrganisation!(member['id'] as String, selectedOrgId!); + }, + child: const Text('Confirmer'), + ), + ], + ), + ), + ); + } + + Future _loadOrganisationsPicker() async { + if (_organisationsPicker.isNotEmpty) return; + try { + final repo = GetIt.instance(); + final orgs = await repo.getOrganizations(page: 0, size: 100); + if (mounted) { + setState(() { + _organisationsPicker = orgs + .where((o) => o.id != null && o.id!.isNotEmpty) + .map((o) => {'id': o.id!, 'nom': o.nomAffichage}) + .toList(); + }); + } + } catch (_) {} + } +} + +// ═══════════════════════════════════════════════════════════════════════════════ +// Page de détail membre (navigation complète) +// ═══════════════════════════════════════════════════════════════════════════════ + +class MemberDetailPage extends StatefulWidget { + final Map member; + final bool isSuperAdmin; + final void Function(String memberId)? onActivateMember; + final void Function(String memberId)? onResetPassword; + final void Function(String memberId, String organisationId)? onAffecterOrganisation; + final void Function(String memberId, String organisationId, String action, String? motif)? onLifecycleAction; + final void Function(String memberId)? onDeleteAccount; + final List> organisationsPicker; + final void Function(List> orgs) onOrganisationsLoaded; + + const MemberDetailPage({ + super.key, + required this.member, + required this.isSuperAdmin, + this.onActivateMember, + this.onResetPassword, + this.onAffecterOrganisation, + this.onLifecycleAction, + this.onDeleteAccount, + required this.organisationsPicker, + required this.onOrganisationsLoaded, + }); + + @override + State createState() => _MemberDetailPageState(); +} + +class _MemberDetailPageState extends State { + late List> _orgs; + + @override + void initState() { + super.initState(); + _orgs = List.from(widget.organisationsPicker); + } + + @override + Widget build(BuildContext context) { + final m = widget.member; + final name = m['name'] as String? ?? ''; + final role = m['role'] as String? ?? 'Membre'; + final status = m['status'] as String? ?? '?'; + final numero = m['numeroMembre'] as String?; + final email = m['email'] as String? ?? '—'; + final phone = m['phone'] as String? ?? ''; + final orgNom = m['organisationNom'] as String?; + final dateAdhesion = m['joinDate']; + final profession = m['department'] as String? ?? ''; + final nationalite = m['nationalite'] as String? ?? ''; + final location = (m['location'] as String? ?? '').trim(); + final cotisAJour = m['cotisationAJour'] as bool? ?? false; + final statutKyc = m['statutKyc'] as String?; + final membreBureau = m['membreBureau'] as bool? ?? false; + final responsable = m['responsable'] as bool? ?? false; + final fonctionBureau = m['fonctionBureau'] as String?; + final eventsCount = m['eventsAttended'] as int? ?? 0; + + return Scaffold( + backgroundColor: Theme.of(context).scaffoldBackgroundColor, + body: CustomScrollView( + slivers: [ + // ── Hero AppBar violet ─────────────────────────────────────────── + SliverAppBar( + expandedHeight: 210, + pinned: true, + backgroundColor: ModuleColors.membres, + foregroundColor: AppColors.onPrimary, + iconTheme: const IconThemeData(color: Colors.white), + actionsIconTheme: const IconThemeData(color: Colors.white), + actions: [ + if (widget.onResetPassword != null) + IconButton( + icon: const Icon(Icons.lock_reset_outlined), + tooltip: 'Réinitialiser le mot de passe', + onPressed: () { + Navigator.pop(context); + widget.onResetPassword!(m['id'] as String); + }, + ), + const SizedBox(width: 4), + ], + flexibleSpace: FlexibleSpaceBar( + background: Container( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [ModuleColors.membresDark, ModuleColors.membres], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + child: SafeArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 32), + Container( + width: 74, + height: 74, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white.withOpacity(0.45), width: 2.5), + gradient: const LinearGradient( + colors: [ModuleColors.membresDark, ModuleColors.membres], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + ), + alignment: Alignment.center, + child: Text( + m['initiales'] as String? ?? '??', + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 28), + ), + ), + const SizedBox(height: 10), + Text(name, style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w700)), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(role, style: TextStyle(color: Colors.white.withOpacity(0.85), fontSize: 13)), + if (numero != null && numero.isNotEmpty) ...[ + Text(' · ', style: TextStyle(color: Colors.white.withOpacity(0.5))), + Text(numero, style: TextStyle(color: Colors.white.withOpacity(0.75), fontSize: 12)), + ], + ], + ), + ], + ), + ), + ), + ), + ), + // ── Contenu ────────────────────────────────────────────────────── + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 40), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Badges statut / cotisation / KYC + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _detailStatusBadge(context, status), + _badgeChip( + context, + cotisAJour ? Icons.payments_outlined : Icons.money_off_outlined, + cotisAJour ? AppColors.success : AppColors.error, + cotisAJour ? 'Cotisation OK' : 'Cotisation en retard', + ), + if (statutKyc != null) _kycBadge(context, statutKyc), + if (membreBureau) _badgeChip(context, Icons.star_outline, AppColors.warningUI, fonctionBureau ?? 'Bureau'), + if (responsable) _badgeChip(context, Icons.manage_accounts_outlined, ModuleColors.membres, 'Responsable'), + ], + ), + const SizedBox(height: 20), + + // Contact + _sectionCard(context, 'Contact', [ + _detailRow(context, Icons.email_outlined, 'Email', email), + if (phone.isNotEmpty) _detailRow(context, Icons.phone_outlined, 'Téléphone', phone), + ]), + const SizedBox(height: 12), + + // Organisation + if (orgNom != null || dateAdhesion != null || eventsCount > 0) + _sectionCard(context, 'Organisation', [ + if (orgNom != null) _detailRow(context, Icons.business_outlined, 'Organisation', orgNom), + if (dateAdhesion != null) _detailRow(context, Icons.calendar_today_outlined, 'Adhésion', _fmt(dateAdhesion)), + if (eventsCount > 0) _detailRow(context, Icons.event_outlined, 'Événements participés', eventsCount.toString()), + ]), + if (orgNom != null || dateAdhesion != null || eventsCount > 0) const SizedBox(height: 12), + + // Profil + if (profession.isNotEmpty || nationalite.isNotEmpty || (location != ',' && location.isNotEmpty)) + _sectionCard(context, 'Profil', [ + if (profession.isNotEmpty) _detailRow(context, Icons.work_outline, 'Profession', profession), + if (nationalite.isNotEmpty) _detailRow(context, Icons.flag_outlined, 'Nationalité', nationalite), + if (location.isNotEmpty && location != ',') _detailRow(context, Icons.location_on_outlined, 'Localisation', location), + ]), + if (profession.isNotEmpty || nationalite.isNotEmpty || (location != ',' && location.isNotEmpty)) const SizedBox(height: 20), + + // Actions + _sectionLabel(context, 'Actions'), + const SizedBox(height: 10), + if (status == 'En attente' && widget.onActivateMember != null) + _actionBtn(context, 'Activer le membre', Icons.check_circle_outline, AppColors.success, () { + Navigator.pop(context); + widget.onActivateMember!(m['id'] as String); + }), + if (widget.onResetPassword != null) + _actionBtn(context, 'Réinitialiser le mot de passe', Icons.lock_reset_outlined, AppColors.warning, () { + Navigator.pop(context); + widget.onResetPassword!(m['id'] as String); + }, outlined: true), + if (widget.isSuperAdmin && + widget.onAffecterOrganisation != null && + (m['organisationId'] == null || (m['organisationId'] as String).isEmpty)) + _actionBtn(context, 'Affecter à une organisation', Icons.business_outlined, ModuleColors.membres, () => _showAffecterDialog(context), outlined: true), + if (widget.onLifecycleAction != null) + ..._lifecycleButtons(context, m), + // Suppression définitive — SuperAdmin uniquement, ET membre encore actif + if (widget.isSuperAdmin && + widget.onDeleteAccount != null && + (m['actif'] as bool? ?? true)) ...[ + const SizedBox(height: 12), + const Divider(height: 1), + const SizedBox(height: 12), + _actionBtn( + context, + 'Supprimer ce compte', + Icons.delete_forever_outlined, + AppColors.error, + () => _confirmDeleteAccount(context, m), + outlined: true, + ), + ], + // Bannière "Compte désactivé" si le membre a été désactivé + if ((m['actif'] as bool? ?? true) == false) ...[ + const SizedBox(height: 12), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.error.withOpacity( + Theme.of(context).brightness == Brightness.dark ? 0.15 : 0.08), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: AppColors.error.withOpacity(0.3)), + ), + child: Row( + children: [ + const Icon(Icons.block_outlined, color: AppColors.error, size: 18), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Compte désactivé', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.error), + ), + Text( + 'Ce compte a été supprimé et ne peut plus se connecter.', + style: TextStyle( + fontSize: 11, + color: Theme.of(context).brightness == Brightness.dark + ? AppColors.textSecondaryDark + : AppColors.textSecondary), + ), + ], + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ], + ), + ); + } + + // ── Helpers UI ───────────────────────────────────────────────────────────── + + Widget _sectionCard(BuildContext context, String label, List children) { + if (children.isEmpty) return const SizedBox.shrink(); + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant, width: 1), + ), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _sectionLabel(context, label), + const SizedBox(height: 10), + ...children, + ], + ), + ); + } + + Widget _sectionLabel(BuildContext context, String text) { + return Text( + text.toUpperCase(), + style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: ModuleColors.membres, letterSpacing: 0.8), + ); + } + + Widget _detailRow(BuildContext context, IconData icon, String label, String value) { + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 16, color: ModuleColors.membres), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildFilterChip('Tous'), - const SizedBox(width: 8), - _buildFilterChip('Actif'), - const SizedBox(width: 8), - _buildFilterChip('Inactif'), - const SizedBox(width: 8), - _buildFilterChip('En attente'), + Text(label, style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant)), + const SizedBox(height: 2), + Text(value, style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.w500)), ], ), ), @@ -204,547 +1429,64 @@ class _MembersPageWithDataAndPaginationState extends State setState(() => _filterStatus = label), - child: AnimatedContainer( - duration: const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - decoration: BoxDecoration( - color: isSelected ? UnionFlowColors.unionGreen : Colors.transparent, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: isSelected ? UnionFlowColors.unionGreen : UnionFlowColors.border, width: 1), - ), - child: Text( - label, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: isSelected ? Colors.white : UnionFlowColors.textSecondary, - ), - ), - ), - ); - } - - // ── Liste ───────────────────────────────────────────────────────────────── - - Widget _buildMembersList() { - final filtered = widget.members.where((m) { - final matchesSearch = _searchQuery.isEmpty || - (m['name'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) || - (m['email'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()) || - (m['numeroMembre'] as String? ?? '').toLowerCase().contains(_searchQuery.toLowerCase()); - final matchesStatus = _filterStatus == 'Tous' || m['status'] == _filterStatus; - return matchesSearch && matchesStatus; - }).toList(); - - if (filtered.isEmpty) return _buildEmptyState(); - - return RefreshIndicator( - onRefresh: () async => widget.onRefresh(), - color: UnionFlowColors.unionGreen, - child: ListView.separated( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - itemCount: filtered.length, - separatorBuilder: (_, __) => const SizedBox(height: 6), - itemBuilder: (context, index) => _buildMemberCard(filtered[index]), - ), - ); - } - - // ── Carte membre ────────────────────────────────────────────────────────── - - Widget _buildMemberCard(Map member) { - final String? orgName = member['organisationNom'] as String?; - final String? numero = member['numeroMembre'] as String?; - final String status = member['status'] as String? ?? '?'; - - return GestureDetector( - onTap: () => _showMemberDetails(member), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - decoration: BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.circular(10), - border: Border.all(color: UnionFlowColors.border.withOpacity(0.3), width: 1), - ), - child: Row( - children: [ - // Avatar - Container( - width: 38, - height: 38, - decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle), - alignment: Alignment.center, - child: Text( - member['initiales'] as String? ?? '??', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700, fontSize: 14), - ), - ), - const SizedBox(width: 12), - // Infos principales - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Nom + numéro - Row( - children: [ - Expanded( - child: Text( - member['name'] as String? ?? 'Inconnu', - style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: UnionFlowColors.textPrimary), - overflow: TextOverflow.ellipsis, - ), - ), - if (numero != null && numero.isNotEmpty) ...[ - const SizedBox(width: 6), - Text( - numero, - style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w500, color: UnionFlowColors.textTertiary), - ), - ], - ], - ), - const SizedBox(height: 2), - // Rôle - Text( - member['role'] as String? ?? 'Membre', - style: const TextStyle(fontSize: 12, color: UnionFlowColors.textSecondary), - ), - // Organisation (SUPER_ADMIN uniquement) - if (_isSuperAdmin && orgName != null && orgName.isNotEmpty) ...[ - const SizedBox(height: 3), - Row( - children: [ - const Icon(Icons.business_outlined, size: 11, color: UnionFlowColors.unionGreen), - const SizedBox(width: 4), - Expanded( - child: Text( - orgName, - style: const TextStyle(fontSize: 11, color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w500), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - // Email - if (member['email'] != null) ...[ - const SizedBox(height: 3), - Row( - children: [ - const Icon(Icons.email_outlined, size: 11, color: UnionFlowColors.textTertiary), - const SizedBox(width: 4), - Expanded( - child: Text( - member['email'] as String, - style: const TextStyle(fontSize: 11, color: UnionFlowColors.textTertiary), - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ], - ], - ), - ), - const SizedBox(width: 8), - _buildStatusBadge(status), - ], - ), - ), - ); - } - - Widget _buildStatusBadge(String status) { - final (Color color, IconData icon) = switch (status) { - 'Actif' => (UnionFlowColors.success, Icons.check_circle_outline), - 'Inactif' => (UnionFlowColors.error, Icons.cancel_outlined), - 'En attente' => (UnionFlowColors.warning, Icons.schedule_outlined), - 'Suspendu' => (const Color(0xFF9E9E9E), Icons.block_outlined), - _ => (UnionFlowColors.textSecondary, Icons.help_outline), - }; + Widget _badgeChip(BuildContext context, IconData icon, Color color, String label) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), decoration: BoxDecoration( color: color.withOpacity(0.1), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: color.withOpacity(0.3), width: 1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: color.withOpacity(0.35), width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 11, color: color), - const SizedBox(width: 4), - Text(status, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), + Icon(icon, size: 12, color: color), + const SizedBox(width: 5), + Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)), ], ), ); } - // ── État vide ───────────────────────────────────────────────────────────── - - Widget _buildEmptyState() { - final hasActiveFilters = _filterStatus != 'Tous' || _searchQuery.isNotEmpty; - return Center( - child: Padding( - padding: const EdgeInsets.all(32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - padding: const EdgeInsets.all(18), - decoration: const BoxDecoration(color: UnionFlowColors.unionGreenPale, shape: BoxShape.circle), - child: Icon( - hasActiveFilters ? Icons.filter_list_off : Icons.people_outline, - size: 40, - color: UnionFlowColors.unionGreen, - ), - ), - const SizedBox(height: 16), - Text( - hasActiveFilters ? 'Aucun résultat' : 'Aucun membre', - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), - ), - const SizedBox(height: 8), - Text( - hasActiveFilters - ? 'Modifiez la recherche ou le filtre de statut' - : 'Aucun membre enregistré pour le moment', - textAlign: TextAlign.center, - style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), - ), - if (hasActiveFilters) ...[ - const SizedBox(height: 16), - GestureDetector( - onTap: () { - setState(() { - _filterStatus = 'Tous'; - _searchQuery = ''; - _searchController.clear(); - }); - widget.onSearch?.call(null); - }, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - border: Border.all(color: UnionFlowColors.unionGreen), - borderRadius: BorderRadius.circular(20), - ), - child: const Text('Effacer les filtres', style: TextStyle(fontSize: 13, color: UnionFlowColors.unionGreen, fontWeight: FontWeight.w600)), - ), - ), - ], - ], - ), - ), - ); + Widget _kycBadge(BuildContext context, String statutKyc) { + final (Color color, IconData icon, String label) = switch (statutKyc) { + 'VERIFIE' => (AppColors.success, Icons.verified_user_outlined, 'KYC Vérifié'), + 'EN_COURS' => (AppColors.warning, Icons.pending_outlined, 'KYC En cours'), + 'REJETE' => (AppColors.error, Icons.gpp_bad_outlined, 'KYC Rejeté'), + _ => (AppColors.textTertiary, Icons.help_outline, 'KYC Inconnu'), + }; + return _badgeChip(context, icon, color, label); } - // ── Pagination ──────────────────────────────────────────────────────────── - - Widget _buildPagination() { + Widget _detailStatusBadge(BuildContext context, String status) { + final (Color color, IconData icon) = switch (status) { + 'Actif' => (AppColors.success, Icons.check_circle_outline), + 'Inactif' => (AppColors.error, Icons.cancel_outlined), + 'En attente' => (AppColors.warning, Icons.schedule_outlined), + 'Suspendu' => (AppColors.textTertiary, Icons.block_outlined), + _ => (AppColors.textTertiary, Icons.help_outline), + }; return Container( - padding: const EdgeInsets.symmetric(vertical: 10), + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( - color: UnionFlowColors.surface, - border: Border(top: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)), + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.4), width: 1), ), child: Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, children: [ - IconButton( - icon: const Icon(Icons.chevron_left, size: 24), - color: widget.currentPage > 0 ? UnionFlowColors.unionGreen : UnionFlowColors.textTertiary, - onPressed: widget.currentPage > 0 - ? () => widget.onPageChanged(widget.currentPage - 1, _searchQuery.isEmpty ? null : _searchQuery) - : null, - ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration(gradient: UnionFlowColors.primaryGradient, borderRadius: BorderRadius.circular(20)), - child: Text( - 'Page ${widget.currentPage + 1} / ${widget.totalPages}', - style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white), - ), - ), - IconButton( - icon: const Icon(Icons.chevron_right, size: 24), - color: widget.currentPage < widget.totalPages - 1 ? UnionFlowColors.unionGreen : UnionFlowColors.textTertiary, - onPressed: widget.currentPage < widget.totalPages - 1 - ? () => widget.onPageChanged(widget.currentPage + 1, _searchQuery.isEmpty ? null : _searchQuery) - : null, - ), + Icon(icon, size: 13, color: color), + const SizedBox(width: 5), + Text(status, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: color)), ], ), ); } - // ── Sheet détail membre ─────────────────────────────────────────────────── - - void _showMemberDetails(Map member) { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (context) => DraggableScrollableSheet( - initialChildSize: 0.55, - minChildSize: 0.4, - maxChildSize: 0.85, - expand: false, - builder: (context, scrollController) => Container( - decoration: const BoxDecoration( - color: UnionFlowColors.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - // Drag handle - Container( - margin: const EdgeInsets.only(top: 10), - width: 36, - height: 4, - decoration: BoxDecoration(color: UnionFlowColors.border, borderRadius: BorderRadius.circular(2)), - ), - Expanded( - child: SingleChildScrollView( - controller: scrollController, - padding: const EdgeInsets.fromLTRB(20, 16, 20, 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // En-tête avatar + nom + statut - Row( - children: [ - Container( - width: 56, - height: 56, - decoration: const BoxDecoration(gradient: UnionFlowColors.primaryGradient, shape: BoxShape.circle), - alignment: Alignment.center, - child: Text( - member['initiales'] as String? ?? '??', - style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w900, fontSize: 22), - ), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - member['name'] as String? ?? '', - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: UnionFlowColors.textPrimary), - ), - const SizedBox(height: 3), - Text( - member['role'] as String? ?? 'Membre', - style: const TextStyle(fontSize: 13, color: UnionFlowColors.textSecondary), - ), - if (member['numeroMembre'] != null) ...[ - const SizedBox(height: 3), - Text( - member['numeroMembre'] as String, - style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w500, color: UnionFlowColors.textTertiary), - ), - ], - ], - ), - ), - _buildStatusBadge(member['status'] as String? ?? '?'), - ], - ), - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 12), - // Infos contact - _buildSectionTitle('Contact'), - _buildDetailRow(Icons.email_outlined, 'Email', member['email'] as String? ?? '—'), - if ((member['phone'] as String? ?? '').isNotEmpty) - _buildDetailRow(Icons.phone_outlined, 'Téléphone', member['phone'] as String), - const SizedBox(height: 12), - // Infos organisation - if (member['organisationNom'] != null || member['organisationId'] != null) ...[ - _buildSectionTitle('Organisation'), - if (member['organisationNom'] != null) - _buildDetailRow(Icons.business_outlined, 'Organisation', member['organisationNom'] as String), - if (member['dateAdhesion'] != null) - _buildDetailRow(Icons.calendar_today_outlined, 'Adhésion', _formatDate(member['dateAdhesion'])), - const SizedBox(height: 12), - ], - // Infos pro - if ((member['department'] as String? ?? '').isNotEmpty) ...[ - _buildSectionTitle('Profil'), - _buildDetailRow(Icons.work_outline, 'Profession', member['department'] as String), - if ((member['nationalite'] as String? ?? '').isNotEmpty) - _buildDetailRow(Icons.flag_outlined, 'Nationalité', member['nationalite'] as String), - if ((member['location'] as String? ?? ', ').trim() != ',') - _buildDetailRow(Icons.location_on_outlined, 'Localisation', member['location'] as String), - ], - // Bouton d'activation (ADMIN_ORGANISATION uniquement, membre en attente) - if (member['status'] == 'En attente' && widget.onActivateMember != null) ...[ - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () { - Navigator.pop(context); - widget.onActivateMember!(member['id'] as String); - }, - icon: const Icon(Icons.check_circle_outline, size: 18), - label: const Text('Activer le membre', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), - style: ElevatedButton.styleFrom( - backgroundColor: UnionFlowColors.success, - foregroundColor: Colors.white, - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ), - ], - // Bouton reset mot de passe (tous membres avec compte Keycloak) - if (widget.onResetPassword != null) ...[ - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () { - Navigator.pop(context); - widget.onResetPassword!(member['id'] as String); - }, - icon: const Icon(Icons.lock_reset_outlined, size: 18), - label: const Text('Réinitialiser le mot de passe', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), - style: OutlinedButton.styleFrom( - foregroundColor: UnionFlowColors.warning, - side: const BorderSide(color: UnionFlowColors.warning), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ), - ], - // Bouton affectation organisation (superadmin, membre sans organisation) - if (_isSuperAdmin && - widget.onAffecterOrganisation != null && - (member['organisationId'] == null || (member['organisationId'] as String).isEmpty)) ...[ - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => _showAffecterOrganisationDialog(context, member), - icon: const Icon(Icons.business_outlined, size: 18), - label: const Text('Affecter à une organisation', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), - style: OutlinedButton.styleFrom( - foregroundColor: UnionFlowColors.unionGreen, - side: const BorderSide(color: UnionFlowColors.unionGreen), - padding: const EdgeInsets.symmetric(vertical: 12), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ), - ), - ], - // ── Boutons cycle de vie adhésion (ADMIN_ORGANISATION) ── - if (!_isSuperAdmin && widget.onLifecycleAction != null) ...[ - const SizedBox(height: 16), - const Divider(height: 1), - const SizedBox(height: 8), - _buildLifecycleActionsSection(context, member), - ], - ], - ), - ), - ), - ], - ), - ), - ), - ); - } - - Widget _buildLifecycleActionsSection(BuildContext context, Map member) { - final memberId = member['id'] as String? ?? ''; - final statut = member['statutMembre'] as String? ?? member['status'] as String? ?? ''; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(bottom: 8), - child: Text('Actions adhésion', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.grey)), - ), - // Activer (INVITE ou EN_ATTENTE_VALIDATION) - if (statut == 'INVITE' || statut == 'EN_ATTENTE_VALIDATION' || statut == 'En attente') - _lifecycleButton( - context: context, - label: 'Activer l\'adhésion', - icon: Icons.check_circle_outline, - color: Colors.green, - onPressed: () { - Navigator.pop(context); - widget.onLifecycleAction!(memberId, 'activer', null); - }, - ), - // Suspendre (ACTIF) - if (statut == 'ACTIF' || statut == 'Actif') - _lifecycleButton( - context: context, - label: 'Suspendre l\'adhésion', - icon: Icons.pause_circle_outline, - color: Colors.orange, - outlined: true, - onPressed: () => _showMotifDialog( - context, - 'Suspendre l\'adhésion', - onConfirm: (motif) => widget.onLifecycleAction!(memberId, 'suspendre', motif), - ), - ), - // Réactiver (SUSPENDU) - if (statut == 'SUSPENDU' || statut == 'Suspendu') - _lifecycleButton( - context: context, - label: 'Réactiver l\'adhésion', - icon: Icons.play_circle_outline, - color: Colors.blue, - onPressed: () { - Navigator.pop(context); - widget.onLifecycleAction!(memberId, 'activer', null); - }, - ), - // Radier (tout statut actif) - if (statut != 'RADIE' && statut != 'ARCHIVE' && statut.isNotEmpty) - _lifecycleButton( - context: context, - label: 'Radier de l\'organisation', - icon: Icons.block_outlined, - color: Colors.red, - outlined: true, - onPressed: () => _showMotifDialog( - context, - 'Radier le membre', - onConfirm: (motif) => widget.onLifecycleAction!(memberId, 'radier', motif), - ), - ), - ], - ); - } - - Widget _lifecycleButton({ - required BuildContext context, - required String label, - required IconData icon, - required Color color, - bool outlined = false, - required VoidCallback onPressed, - }) { + Widget _actionBtn(BuildContext context, String label, IconData icon, Color color, VoidCallback onPressed, {bool outlined = false}) { return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.only(bottom: 10), child: SizedBox( width: double.infinity, child: outlined @@ -755,8 +1497,8 @@ class _MembersPageWithDataAndPaginationState extends State _lifecycleButtons(BuildContext context, Map m) { + final memberId = m['id'] as String? ?? ''; + final statut = m['statutMembre'] as String? ?? m['status'] as String? ?? ''; + // Organisation du membre — utilisée pour les actions lifecycle. + // En contexte orgAdmin, c'est l'org fixe ; en superAdmin, on prend l'org du membre. + final orgId = m['organisationId'] as String? ?? ''; + + // Un orgAdmin ne peut pas agir sur un autre admin ou super-admin. + // Le superAdmin peut agir sur tout le monde — pas de restriction. + final roleCode = (m['roleCode'] as String? ?? '').toUpperCase(); + final cibleEstAdmin = roleCode == 'ORGADMIN' + || roleCode == 'SUPERADMIN' + || roleCode == 'ADMIN_ORGANISATION' + || roleCode == 'SUPER_ADMIN'; + if (cibleEstAdmin && !widget.isSuperAdmin) return const []; + + // Si on n'a pas d'org pour ce membre, impossible d'envoyer les actions lifecycle + if (orgId.isEmpty) return const []; + + return [ + if (statut == 'INVITE' || statut == 'EN_ATTENTE_VALIDATION' || statut == 'En attente') + _actionBtn(context, "Activer l'adhésion", Icons.check_circle_outline, AppColors.success, () { + Navigator.pop(context); + widget.onLifecycleAction!(memberId, orgId, 'activer', null); + }), + if (statut == 'ACTIF' || statut == 'Actif') + _actionBtn(context, "Suspendre l'adhésion", Icons.pause_circle_outline, AppColors.warning, () { + _showMotifDialog(context, "Suspendre l'adhésion", + onConfirm: (motif) => widget.onLifecycleAction!(memberId, orgId, 'suspendre', motif)); + }, outlined: true), + if (statut == 'SUSPENDU' || statut == 'Suspendu') + _actionBtn(context, "Réactiver l'adhésion", Icons.play_circle_outline, AppColors.info, () { + Navigator.pop(context); + widget.onLifecycleAction!(memberId, orgId, 'activer', null); + }), + if (statut != 'RADIE' && statut != 'ARCHIVE' && statut.isNotEmpty) + _actionBtn(context, "Radier de l'organisation", Icons.block_outlined, AppColors.error, () { + _showMotifDialog(context, 'Radier le membre', + onConfirm: (motif) => widget.onLifecycleAction!(memberId, orgId, 'radier', motif)); + }, outlined: true), + ]; + } + + void _showMotifDialog(BuildContext context, String titre, {required void Function(String? motif) onConfirm}) { + final ctrl = TextEditingController(); showDialog( context: context, builder: (ctx) => AlertDialog( title: Text(titre), content: TextField( - controller: motifCtrl, - decoration: const InputDecoration( - labelText: 'Motif (optionnel)', - border: OutlineInputBorder(), - ), + controller: ctrl, + decoration: const InputDecoration(labelText: 'Motif (optionnel)', border: OutlineInputBorder()), maxLines: 3, ), actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('Annuler'), - ), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary), onPressed: () { - Navigator.pop(ctx); // ferme dialog motif - Navigator.pop(context); // ferme bottom sheet - onConfirm(motifCtrl.text.isNotEmpty ? motifCtrl.text : null); + Navigator.pop(ctx); + Navigator.pop(context); + onConfirm(ctrl.text.isNotEmpty ? ctrl.text : null); }, child: const Text('Confirmer'), ), @@ -810,62 +1586,147 @@ class _MembersPageWithDataAndPaginationState extends State _showAffecterOrganisationDialog( - BuildContext ctx, - Map member, - ) async { - // Charger les organisations si pas encore fait - if (_organisationsPicker.isEmpty) { + /// Confirmation de suppression définitive (SuperAdmin only). + /// Protection anti-clic — double confirmation + saisie du nom. + Future _confirmDeleteAccount(BuildContext context, Map m) async { + final isDark = Theme.of(context).brightness == Brightness.dark; + final name = (m['name'] as String? ?? '').trim(); + final memberId = m['id'] as String? ?? ''; + final roleCode = (m['roleCode'] as String? ?? '').toUpperCase(); + final isAdminTarget = roleCode == 'ORGADMIN' || roleCode == 'ADMIN_ORGANISATION'; + final textPrimary = isDark ? AppColors.textPrimaryDark : AppColors.textPrimary; + final textSecondary = isDark ? AppColors.textSecondaryDark : AppColors.textSecondary; + + final confirmed = await showDialog( + context: context, + barrierDismissible: false, + builder: (ctx) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + backgroundColor: isDark ? AppColors.surfaceDark : AppColors.surface, + title: Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(isDark ? 0.2 : 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.warning_amber_rounded, color: AppColors.error, size: 22), + ), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Supprimer ce compte ?', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: textPrimary), + ), + ), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Vous êtes sur le point de supprimer le compte de :', + style: TextStyle(fontSize: 13, color: textSecondary), + ), + const SizedBox(height: 8), + Container( + width: double.infinity, + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.error.withOpacity(isDark ? 0.12 : 0.06), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: AppColors.error.withOpacity(0.3)), + ), + child: Row( + children: [ + if (isAdminTarget) ...[ + Icon(Icons.admin_panel_settings_outlined, size: 16, color: AppColors.error), + const SizedBox(width: 6), + ], + Expanded( + child: Text( + name.isEmpty ? '(sans nom)' : name, + style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: textPrimary), + ), + ), + ], + ), + ), + const SizedBox(height: 12), + Text( + isAdminTarget + ? 'Ce membre est administrateur d\'organisation. Sa suppression désactivera son compte et suspendra toutes ses adhésions.' + : 'Le compte sera désactivé et toutes ses adhésions seront suspendues. Cette action est irréversible.', + style: TextStyle(fontSize: 12, color: textSecondary, height: 1.4), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: Text('Annuler', style: TextStyle(color: textSecondary, fontWeight: FontWeight.w600)), + ), + ElevatedButton.icon( + onPressed: () => Navigator.of(ctx).pop(true), + icon: const Icon(Icons.delete_forever, size: 18), + label: const Text('Supprimer', style: TextStyle(fontWeight: FontWeight.w700)), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.error, + foregroundColor: AppColors.onError, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + ), + ), + ], + ), + ); + + if (confirmed == true && context.mounted) { + // Fermer l'action sheet en plus du dialog + Navigator.of(context).pop(); + widget.onDeleteAccount!(memberId); + } + } + + Future _showAffecterDialog(BuildContext context) async { + if (_orgs.isEmpty) { try { final repo = GetIt.instance(); final orgs = await repo.getOrganizations(page: 0, size: 100); if (mounted) { setState(() { - _organisationsPicker = orgs - .where((o) => o.id != null && o.id!.isNotEmpty) - .map((o) => {'id': o.id!, 'nom': o.nomAffichage}) - .toList(); + _orgs = orgs.where((o) => o.id != null && o.id!.isNotEmpty).map((o) => {'id': o.id!, 'nom': o.nomAffichage}).toList(); + widget.onOrganisationsLoaded(_orgs); }); } } catch (_) {} } - if (!mounted) return; - String? selectedOrgId; - await showDialog( - context: ctx, - builder: (dialogCtx) => StatefulBuilder( - builder: (dialogCtx, setDialogState) => AlertDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, set) => AlertDialog( title: const Text('Affecter à une organisation'), - content: _organisationsPicker.isEmpty + content: _orgs.isEmpty ? const Text('Aucune organisation disponible.') : DropdownButtonFormField( decoration: const InputDecoration(labelText: 'Organisation'), - items: _organisationsPicker - .map((o) => DropdownMenuItem( - value: o['id'], - child: Text(o['nom']!), - )) - .toList(), - onChanged: (v) => setDialogState(() => selectedOrgId = v), + items: _orgs.map((o) => DropdownMenuItem(value: o['id'], child: Text(o['nom']!))).toList(), + onChanged: (v) => set(() => selectedOrgId = v), ), actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogCtx), - child: const Text('Annuler'), - ), + TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary), onPressed: selectedOrgId == null ? null : () { - Navigator.pop(dialogCtx); - Navigator.pop(ctx); // ferme le bottom sheet - widget.onAffecterOrganisation!( - member['id'] as String, - selectedOrgId!, - ); + Navigator.pop(ctx); + Navigator.pop(context); + widget.onAffecterOrganisation!(widget.member['id'] as String, selectedOrgId!); }, child: const Text('Confirmer'), ), @@ -875,42 +1736,25 @@ class _MembersPageWithDataAndPaginationState extends State().add(LoadMembres(refresh: true, organisationId: organisationId)); } + // Accès refusé sur une action lifecycle (403) — SnackBar ciblé + rechargement liste + if (state is MembresActionForbidden) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row(children: [ + const Icon(Icons.block_outlined, color: Colors.white, size: 18), + const SizedBox(width: 8), + Expanded(child: Text(state.message)), + ]), + backgroundColor: ColorTokens.error, + duration: const Duration(seconds: 5), + ), + ); + // Recharger pour revenir à l'état MembresLoaded (évite la page d'erreur plein écran) + context.read().add(LoadMembres(organisationId: organisationId)); + return; + } + // Gestion des erreurs avec SnackBar if (state is MembresError) { final bloc = context.read(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(state.message), - backgroundColor: Colors.red, + backgroundColor: ColorTokens.error, duration: const Duration(seconds: 4), action: SnackBarAction( label: 'Réessayer', @@ -84,13 +103,31 @@ class MembersPageConnected extends StatelessWidget { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Membre activé avec succès'), - backgroundColor: Colors.green, + backgroundColor: ColorTokens.success, duration: Duration(seconds: 3), ), ); context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); } + // Après suppression définitive : feedback + pop la page de détail si ouverte + rechargement + if (state is MembreDeleted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row(children: const [ + Icon(Icons.delete_forever, color: Colors.white, size: 18), + SizedBox(width: 8), + Expanded(child: Text('Compte supprimé avec succès')), + ]), + backgroundColor: ColorTokens.error, + duration: const Duration(seconds: 3), + ), + ); + // Si on est dans MemberDetailPage, revenir à la liste (pop unique) + Navigator.of(context).maybePop(); + context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); + } + // Après reset mot de passe : afficher le dialog credentials if (state is MotDePasseReinitialise) { showCredentialsDialog(context, state.membre); @@ -101,7 +138,7 @@ class MembersPageConnected extends StatelessWidget { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('Membre affecté à l\'organisation avec succès'), - backgroundColor: Colors.green, + backgroundColor: ColorTokens.success, duration: Duration(seconds: 3), ), ); @@ -111,25 +148,25 @@ class MembersPageConnected extends StatelessWidget { // Lifecycle adhésion : succès + rechargement if (state is MembreInvite) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Invitation envoyée'), backgroundColor: Colors.blue, duration: Duration(seconds: 3)), + const SnackBar(content: Text('Invitation envoyée'), backgroundColor: ColorTokens.info, duration: Duration(seconds: 3)), ); context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); } if (state is AdhesionActivee) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Adhésion activée'), backgroundColor: Colors.green, duration: Duration(seconds: 3)), + const SnackBar(content: Text('Adhésion activée'), backgroundColor: ColorTokens.success, duration: Duration(seconds: 3)), ); context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); } if (state is AdhesionSuspendue) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Adhésion suspendue'), backgroundColor: Colors.orange, duration: Duration(seconds: 3)), + const SnackBar(content: Text('Adhésion suspendue'), backgroundColor: ColorTokens.warning, duration: Duration(seconds: 3)), ); context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); } if (state is MembreRadie) { ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Membre radié de l\'organisation'), backgroundColor: Colors.red, duration: Duration(seconds: 3)), + const SnackBar(content: Text('Membre radié de l\'organisation'), backgroundColor: ColorTokens.error, duration: Duration(seconds: 3)), ); context.read().add(LoadMembres(refresh: true, organisationId: organisationId)); } @@ -141,7 +178,7 @@ class MembersPageConnected extends StatelessWidget { // État initial if (state is MembresInitial) { return Container( - color: const Color(0xFFF8F9FA), + color: null, child: const Center( child: AppLoadingWidget(message: 'Initialisation...'), ), @@ -151,7 +188,7 @@ class MembersPageConnected extends StatelessWidget { // État de chargement if (state is MembresLoading) { return Container( - color: const Color(0xFFF8F9FA), + color: null, child: const Center( child: AppLoadingWidget(message: 'Chargement des membres...'), ), @@ -162,7 +199,7 @@ class MembersPageConnected extends StatelessWidget { if (state is MembresRefreshing) { // Affiche un indicateur pendant le rafraîchissement return Container( - color: const Color(0xFFF8F9FA), + color: null, child: const Center( child: AppLoadingWidget(message: 'Actualisation...'), ), @@ -172,7 +209,7 @@ class MembersPageConnected extends StatelessWidget { // Après création : on recharge la liste (listener a dispatché LoadMembres) if (state is MembreCreated) { return Container( - color: const Color(0xFFF8F9FA), + color: null, child: const Center( child: AppLoadingWidget(message: 'Actualisation...'), ), @@ -216,25 +253,35 @@ class MembersPageConnected extends StatelessWidget { context.read().add(AffecterOrganisation(memberId, orgId)); } : null, - onLifecycleAction: organisationId != null - ? (memberId, action, motif) { - final bloc = context.read(); - switch (action) { - case 'inviter': - bloc.add(InviterMembre(membreId: memberId, organisationId: organisationId!)); - break; - case 'activer': - bloc.add(ActiverAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif)); - break; - case 'suspendre': - bloc.add(SuspendrAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif)); - break; - case 'radier': - bloc.add(RadierAdhesion(membreId: memberId, organisationId: organisationId!, motif: motif)); - break; - } + // Suppression définitive — SuperAdmin uniquement (organisationId == null) + // Backend : DELETE /api/membres/{id} @RolesAllowed({ ADMIN, SUPER_ADMIN }) + onDeleteAccount: organisationId == null + ? (memberId) { + context.read().add(DeleteMembre(memberId)); } : null, + // onLifecycleAction est toujours fourni. + // orgAdmin : organisationId fixe (contexte org). + // superAdmin : organisationId provient du membre lui-même (passé par _lifecycleButtons). + onLifecycleAction: (memberId, targetOrgId, action, motif) { + final bloc = context.read(); + // En contexte orgAdmin on utilise l'org fixe, en superAdmin l'org du membre + final effectiveOrgId = organisationId ?? targetOrgId; + switch (action) { + case 'inviter': + bloc.add(InviterMembre(membreId: memberId, organisationId: effectiveOrgId)); + break; + case 'activer': + bloc.add(ActiverAdhesion(membreId: memberId, organisationId: effectiveOrgId, motif: motif)); + break; + case 'suspendre': + bloc.add(SuspendrAdhesion(membreId: memberId, organisationId: effectiveOrgId, motif: motif)); + break; + case 'radier': + bloc.add(RadierAdhesion(membreId: memberId, organisationId: effectiveOrgId, motif: motif)); + break; + } + }, ); } @@ -242,7 +289,7 @@ class MembersPageConnected extends StatelessWidget { if (state is MembresNetworkError) { AppLogger.error('MembersPageConnected: Erreur réseau', error: state.message); return Container( - color: const Color(0xFFF8F9FA), + color: null, child: NetworkErrorWidget( onRetry: () { AppLogger.userAction('Retry load membres after network error'); @@ -256,7 +303,7 @@ class MembersPageConnected extends StatelessWidget { if (state is MembresError) { AppLogger.error('MembersPageConnected: Erreur', error: state.message); return Container( - color: const Color(0xFFF8F9FA), + color: null, child: AppErrorWidget( message: state.message, onRetry: () { @@ -270,7 +317,7 @@ class MembersPageConnected extends StatelessWidget { // État par défaut (ne devrait jamais arriver) AppLogger.warning('MembersPageConnected: État non géré: ${state.runtimeType}'); return Container( - color: const Color(0xFFF8F9FA), + color: null, child: const Center( child: AppLoadingWidget(message: 'Chargement...'), ), @@ -292,6 +339,8 @@ class MembersPageConnected extends StatelessWidget { 'id': membre.id ?? '', 'name': membre.nomComplet, 'email': membre.email, + 'actif': membre.actif, // flag global désactivation (soft delete) + 'roleCode': membre.role, // code brut ex: ORGADMIN, SUPERADMIN — utilisé pour les checks de permission 'role': _mapRoleToString(membre.role), 'status': _mapStatutToString(membre.statut), 'joinDate': membre.dateAdhesion, @@ -323,7 +372,11 @@ class MembersPageConnected extends StatelessWidget { 'fonctionBureau': membre.fonctionBureau, 'numeroMembre': membre.numeroMembre, 'cotisationAJour': membre.cotisationAJour, - + 'organisationNom': membre.organisationNom, + 'statutKyc': membre.statutKyc?.name, + 'niveauVigilanceKyc': membre.niveauVigilanceKyc?.name, + 'statutMembre': membre.statut?.name, + // Propriétés calculées 'initiales': membre.initiales, 'age': membre.age,