Auth: - profile_repository.dart: /api/auth/change-password → /api/membres/auth/change-password Multi-org (Phase 3): - OrgSelectorPage, OrgSwitcherBloc, OrgSwitcherEntry - org_context_service.dart: headers X-Active-Organisation-Id + X-Active-Role Navigation: - MorePage: navigation conditionnelle par typeOrganisation - Suppression adaptive_navigation (remplacé par main_navigation_layout) Auth AppAuth: - keycloak_webview_auth_service: fixes AppAuth Android - AuthBloc: gestion REAUTH_REQUIS + premierLoginComplet Onboarding: - Nouveaux états: payment_method_page, onboarding_shared_widgets - SouscriptionStatusModel mis à jour StatutValidationSouscription Android: - build.gradle: ProGuard/R8, network_security_config - Gradle wrapper mis à jour
917 lines
39 KiB
Dart
917 lines
39 KiB
Dart
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/components/uf_app_bar.dart';
|
|
import '../../../../core/constants/app_constants.dart';
|
|
import '../../../../features/organizations/domain/repositories/organization_repository.dart';
|
|
|
|
/// Annuaire des Membres - Design UnionFlow
|
|
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;
|
|
/// 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;
|
|
|
|
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,
|
|
});
|
|
|
|
@override
|
|
State<MembersPageWithDataAndPagination> createState() => _MembersPageWithDataAndPaginationState();
|
|
}
|
|
|
|
class _MembersPageWithDataAndPaginationState extends State<MembersPageWithDataAndPagination> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
String _searchQuery = '';
|
|
String _filterStatus = 'Tous';
|
|
Timer? _searchDebounce;
|
|
|
|
// Organisations pour le picker d'affectation (superadmin)
|
|
List<Map<String, String>> _organisationsPicker = [];
|
|
|
|
bool get _isSuperAdmin => widget.organisationId == null;
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchDebounce?.cancel();
|
|
_searchController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@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),
|
|
],
|
|
),
|
|
body: Column(
|
|
children: [
|
|
_buildHeader(),
|
|
_buildSearchAndFilters(),
|
|
Expanded(child: _buildMembersList()),
|
|
if (widget.totalPages > 1) _buildPagination(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Header ────────────────────────────────────────────────────────────────
|
|
|
|
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;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.surface,
|
|
border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), 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')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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),
|
|
),
|
|
child: Column(
|
|
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))),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Recherche + Filtres ────────────────────────────────────────────────────
|
|
|
|
Widget _buildSearchAndFilters() {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.surface,
|
|
border: Border(bottom: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
TextField(
|
|
controller: _searchController,
|
|
onChanged: (v) {
|
|
setState(() => _searchQuery = v);
|
|
_searchDebounce?.cancel();
|
|
_searchDebounce = Timer(AppConstants.searchDebounce, () {
|
|
widget.onSearch?.call(v.isEmpty ? null : v);
|
|
});
|
|
},
|
|
style: const TextStyle(fontSize: 14, color: UnionFlowColors.textPrimary),
|
|
decoration: InputDecoration(
|
|
hintText: 'Nom, email, numéro membre...',
|
|
hintStyle: const TextStyle(fontSize: 13, color: UnionFlowColors.textTertiary),
|
|
prefixIcon: const Icon(Icons.search, size: 20, color: UnionFlowColors.textSecondary),
|
|
suffixIcon: _searchQuery.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, size: 18, color: UnionFlowColors.textSecondary),
|
|
onPressed: () {
|
|
_searchDebounce?.cancel();
|
|
_searchController.clear();
|
|
setState(() => _searchQuery = '');
|
|
widget.onSearch?.call(null);
|
|
},
|
|
)
|
|
: 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)),
|
|
filled: true,
|
|
fillColor: UnionFlowColors.surfaceVariant.withOpacity(0.3),
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_buildFilterChip('Tous'),
|
|
const SizedBox(width: 8),
|
|
_buildFilterChip('Actif'),
|
|
const SizedBox(width: 8),
|
|
_buildFilterChip('Inactif'),
|
|
const SizedBox(width: 8),
|
|
_buildFilterChip('En attente'),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFilterChip(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 ? 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<String, dynamic> 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),
|
|
};
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withOpacity(0.3), 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)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── É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)),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Pagination ────────────────────────────────────────────────────────────
|
|
|
|
Widget _buildPagination() {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: UnionFlowColors.surface,
|
|
border: Border(top: BorderSide(color: UnionFlowColors.border.withOpacity(0.5), width: 1)),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ── Sheet détail membre ───────────────────────────────────────────────────
|
|
|
|
void _showMemberDetails(Map<String, dynamic> 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<String, dynamic> 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,
|
|
}) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
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: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
)
|
|
: 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: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showMotifDialog(
|
|
BuildContext context,
|
|
String titre, {
|
|
required void Function(String? motif) onConfirm,
|
|
}) {
|
|
final motifCtrl = TextEditingController();
|
|
showDialog<void>(
|
|
context: context,
|
|
builder: (ctx) => AlertDialog(
|
|
title: Text(titre),
|
|
content: TextField(
|
|
controller: motifCtrl,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Motif (optionnel)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(ctx),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(ctx); // ferme dialog motif
|
|
Navigator.pop(context); // ferme bottom sheet
|
|
onConfirm(motifCtrl.text.isNotEmpty ? motifCtrl.text : null);
|
|
},
|
|
child: const Text('Confirmer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _showAffecterOrganisationDialog(
|
|
BuildContext ctx,
|
|
Map<String, dynamic> member,
|
|
) async {
|
|
// Charger les organisations si pas encore fait
|
|
if (_organisationsPicker.isEmpty) {
|
|
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 (_) {}
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
String? selectedOrgId;
|
|
|
|
await showDialog<void>(
|
|
context: ctx,
|
|
builder: (dialogCtx) => StatefulBuilder(
|
|
builder: (dialogCtx, setDialogState) => 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<String>(
|
|
value: o['id'],
|
|
child: Text(o['nom']!),
|
|
))
|
|
.toList(),
|
|
onChanged: (v) => setDialogState(() => selectedOrgId = v),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogCtx),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: selectedOrgId == null
|
|
? null
|
|
: () {
|
|
Navigator.pop(dialogCtx);
|
|
Navigator.pop(ctx); // ferme le bottom sheet
|
|
widget.onAffecterOrganisation!(
|
|
member['id'] as String,
|
|
selectedOrgId!,
|
|
);
|
|
},
|
|
child: const Text('Confirmer'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildSectionTitle(String title) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
title.toUpperCase(),
|
|
style: const TextStyle(fontSize: 11, fontWeight: FontWeight.w700, color: UnionFlowColors.textTertiary, letterSpacing: 0.8),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildDetailRow(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: UnionFlowColors.unionGreen),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: const TextStyle(fontSize: 11, color: UnionFlowColors.textTertiary)),
|
|
const SizedBox(height: 1),
|
|
Text(value, style: const TextStyle(fontSize: 13, color: UnionFlowColors.textPrimary, fontWeight: FontWeight.w500)),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatDate(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();
|
|
}
|
|
}
|