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
1761 lines
72 KiB
Dart
1761 lines
72 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:get_it/get_it.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 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<Map<String, dynamic>> members;
|
|
final int totalCount;
|
|
final int currentPage;
|
|
final int totalPages;
|
|
final Function(int page, String? recherche) onPageChanged;
|
|
final VoidCallback onRefresh;
|
|
final void Function(String? query)? onSearch;
|
|
final VoidCallback? onAddMember;
|
|
/// null = SUPER_ADMIN (vue globale, affiche l'organisation sur chaque carte)
|
|
final String? organisationId;
|
|
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;
|
|
/// Suppression définitive du compte (SuperAdmin uniquement — backend @RolesAllowed ADMIN/SUPER_ADMIN).
|
|
final void Function(String memberId)? onDeleteAccount;
|
|
|
|
const MembersPageWithDataAndPagination({
|
|
super.key,
|
|
required this.members,
|
|
required this.totalCount,
|
|
required this.currentPage,
|
|
required this.totalPages,
|
|
required this.onPageChanged,
|
|
required this.onRefresh,
|
|
this.onSearch,
|
|
this.onAddMember,
|
|
this.organisationId,
|
|
this.onActivateMember,
|
|
this.onResetPassword,
|
|
this.onAffecterOrganisation,
|
|
this.onLifecycleAction,
|
|
this.onDeleteAccount,
|
|
});
|
|
|
|
@override
|
|
State<MembersPageWithDataAndPagination> createState() => _MembersPageState();
|
|
}
|
|
|
|
class _MembersPageState extends State<MembersPageWithDataAndPagination> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
String _searchQuery = '';
|
|
String _filterStatus = 'Tous';
|
|
Timer? _searchDebounce;
|
|
|
|
bool _isSearchExpanded = false;
|
|
bool _isGridView = false;
|
|
bool _isSelectionMode = false;
|
|
final Set<String> _selectedIds = {};
|
|
bool _isLoadingMore = false;
|
|
|
|
List<Map<String, dynamic>> _accumulatedMembers = [];
|
|
List<Map<String, String>> _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<Map<String, dynamic>> 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: Theme.of(context).scaffoldBackgroundColor,
|
|
appBar: _isSelectionMode ? _buildSelectionAppBar() : _buildMainAppBar(),
|
|
body: Column(
|
|
children: [
|
|
_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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─── AppBar principale ────────────────────────────────────────────────────
|
|
|
|
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.fromLTRB(12, 8, 12, 10),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).colorScheme.surface,
|
|
border: Border(bottom: BorderSide(color: Theme.of(context).colorScheme.outlineVariant, width: 1)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
_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 _kpiTile(BuildContext context, String label, String value, Color color, IconData icon) {
|
|
return Expanded(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
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),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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>(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 _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, 8, 12, 10),
|
|
decoration: BoxDecoration(
|
|
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,
|
|
onChanged: (v) {
|
|
setState(() => _searchQuery = v);
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(AppConstants.searchDebounce, () {
|
|
widget.onSearch?.call(v.isEmpty ? null : v);
|
|
});
|
|
},
|
|
style: TextStyle(fontSize: 14, color: Theme.of(context).colorScheme.onSurface),
|
|
decoration: InputDecoration(
|
|
hintText: 'Nom, email, numéro membre...',
|
|
hintStyle: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.onSurfaceVariant),
|
|
prefixIcon: Icon(Icons.search, size: 20, color: ModuleColors.membres),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, size: 18),
|
|
onPressed: _clearSearch,
|
|
)
|
|
: null,
|
|
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: Theme.of(context).colorScheme.surfaceContainerHighest,
|
|
),
|
|
),
|
|
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<Map<String, dynamic>> 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<Map<String, dynamic>> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<void> _showBulkAffecterDialog() async {
|
|
await _loadOrganisationsPicker();
|
|
if (!mounted) return;
|
|
String? selectedOrgId;
|
|
await showDialog<void>(
|
|
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<String>(
|
|
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<void> _showAffecterOrganisationDialog(BuildContext ctx, Map<String, dynamic> member) async {
|
|
await _loadOrganisationsPicker();
|
|
if (!mounted) return;
|
|
String? selectedOrgId;
|
|
await showDialog<void>(
|
|
context: ctx,
|
|
builder: (dialogCtx) => StatefulBuilder(
|
|
builder: (dialogCtx, set) => AlertDialog(
|
|
title: const Text('Affecter à une organisation'),
|
|
content: _organisationsPicker.isEmpty
|
|
? const Text('Aucune organisation disponible.')
|
|
: DropdownButtonFormField<String>(
|
|
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<void> _loadOrganisationsPicker() async {
|
|
if (_organisationsPicker.isNotEmpty) return;
|
|
try {
|
|
final repo = GetIt.instance<IOrganizationRepository>();
|
|
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<String, dynamic> 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<Map<String, String>> organisationsPicker;
|
|
final void Function(List<Map<String, String>> 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<MemberDetailPage> createState() => _MemberDetailPageState();
|
|
}
|
|
|
|
class _MemberDetailPageState extends State<MemberDetailPage> {
|
|
late List<Map<String, String>> _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<Widget> 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: [
|
|
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)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _badgeChip(BuildContext context, IconData icon, Color color, String label) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: color.withOpacity(0.35), width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 12, color: color),
|
|
const SizedBox(width: 5),
|
|
Text(label, style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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(horizontal: 10, vertical: 5),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.12),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withOpacity(0.4), width: 1),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(icon, size: 13, color: color),
|
|
const SizedBox(width: 5),
|
|
Text(status, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w700, color: color)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _actionBtn(BuildContext context, String label, IconData icon, Color color, VoidCallback onPressed, {bool outlined = false}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: SizedBox(
|
|
width: double.infinity,
|
|
child: outlined
|
|
? OutlinedButton.icon(
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, size: 18),
|
|
label: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: color,
|
|
side: BorderSide(color: color),
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
)
|
|
: ElevatedButton.icon(
|
|
onPressed: onPressed,
|
|
icon: Icon(icon, size: 18),
|
|
label: Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: color,
|
|
foregroundColor: AppColors.onPrimary,
|
|
padding: const EdgeInsets.symmetric(vertical: 13),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
List<Widget> _lifecycleButtons(BuildContext context, Map<String, dynamic> 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<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(titre),
|
|
content: TextField(
|
|
controller: ctrl,
|
|
decoration: const InputDecoration(labelText: 'Motif (optionnel)', border: OutlineInputBorder()),
|
|
maxLines: 3,
|
|
),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')),
|
|
ElevatedButton(
|
|
style: ElevatedButton.styleFrom(backgroundColor: ModuleColors.membres, foregroundColor: AppColors.onPrimary),
|
|
onPressed: () {
|
|
Navigator.pop(ctx);
|
|
Navigator.pop(context);
|
|
onConfirm(ctrl.text.isNotEmpty ? ctrl.text : null);
|
|
},
|
|
child: const Text('Confirmer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Confirmation de suppression définitive (SuperAdmin only).
|
|
/// Protection anti-clic — double confirmation + saisie du nom.
|
|
Future<void> _confirmDeleteAccount(BuildContext context, Map<String, dynamic> 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<bool>(
|
|
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<void> _showAffecterDialog(BuildContext context) async {
|
|
if (_orgs.isEmpty) {
|
|
try {
|
|
final repo = GetIt.instance<IOrganizationRepository>();
|
|
final orgs = await repo.getOrganizations(page: 0, size: 100);
|
|
if (mounted) {
|
|
setState(() {
|
|
_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<void>(
|
|
context: context,
|
|
builder: (ctx) => StatefulBuilder(
|
|
builder: (ctx, set) => AlertDialog(
|
|
title: const Text('Affecter à une organisation'),
|
|
content: _orgs.isEmpty
|
|
? const Text('Aucune organisation disponible.')
|
|
: DropdownButtonFormField<String>(
|
|
decoration: const InputDecoration(labelText: 'Organisation'),
|
|
items: _orgs.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);
|
|
Navigator.pop(context);
|
|
widget.onAffecterOrganisation!(widget.member['id'] as String, selectedOrgId!);
|
|
},
|
|
child: const Text('Confirmer'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _fmt(dynamic date) {
|
|
if (date == null) return '—';
|
|
if (date is DateTime) {
|
|
return '${date.day.toString().padLeft(2, '0')}/${date.month.toString().padLeft(2, '0')}/${date.year}';
|
|
}
|
|
return date.toString();
|
|
}
|
|
}
|
|
|
|
/// Données d'une action swipe gauche pour les cartes membres
|
|
class _SwipeAction {
|
|
final String action; // 'activer' | 'suspendre' | 'radier'
|
|
final IconData icon;
|
|
final String label;
|
|
final Color color;
|
|
const _SwipeAction({
|
|
required this.action,
|
|
required this.icon,
|
|
required this.label,
|
|
required this.color,
|
|
});
|
|
}
|