feat: WebSocket temps réel + Finance Workflow + corrections
- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics) * Backend: KafkaEventProducer, KafkaEventConsumer * Mobile: WebSocketService (reconnection, heartbeat, typed events) * DashboardBloc: Auto-refresh depuis WebSocket events - Finance Workflow: approbations + budgets (backend + mobile) * Backend: entities, services, resources, migrations Flyway V6 * Mobile: features finance_workflow complète avec BLoC - Corrections DI: interfaces IRepository partout * IProfileRepository, IOrganizationRepository, IMembreRepository * GetIt configuré avec @injectable - Spec-Kit: constitution + templates mis à jour * .specify/memory/constitution.md enrichie * Templates agent, plan, spec, tasks, checklist - Nettoyage: fichiers temporaires supprimés Signed-off-by: lions dev Team
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import '../../../../core/di/injection_container.dart';
|
||||
import '../../../../shared/models/membre_search_criteria.dart';
|
||||
import '../../../../shared/models/membre_search_result.dart';
|
||||
import '../../../organizations/data/repositories/organization_repository.dart';
|
||||
import '../../../organizations/data/models/organization_model.dart';
|
||||
import '../../data/services/membre_search_service.dart';
|
||||
import '../widgets/membre_search_results.dart';
|
||||
import '../widgets/search_statistics_card.dart';
|
||||
@@ -18,8 +20,10 @@ class AdvancedSearchPage extends StatefulWidget {
|
||||
class _AdvancedSearchPageState extends State<AdvancedSearchPage>
|
||||
with TickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final MembreSearchService _searchService = GetIt.instance<MembreSearchService>();
|
||||
late final MembreSearchService _searchService = sl<MembreSearchService>();
|
||||
MembreSearchCriteria _currentCriteria = MembreSearchCriteria.empty;
|
||||
List<OrganizationModel> _organisations = [];
|
||||
bool _organisationsLoaded = false;
|
||||
MembreSearchResult? _currentResult;
|
||||
bool _isSearching = false;
|
||||
String? _errorMessage;
|
||||
@@ -44,10 +48,53 @@ class _AdvancedSearchPageState extends State<AdvancedSearchPage>
|
||||
bool _membreBureau = false;
|
||||
bool _responsable = false;
|
||||
|
||||
/// Rôles Keycloak utilisables pour le filtre (noms envoyés à l'API)
|
||||
static const List<String> _searchRoleCodes = [
|
||||
'MEMBRE_ACTIF',
|
||||
'MEMBRE_SIMPLE',
|
||||
'ADMIN_ORGANISATION',
|
||||
'SECRETAIRE',
|
||||
'TRESORIER',
|
||||
'CONSULTANT',
|
||||
'GESTIONNAIRE_RH',
|
||||
'SUPER_ADMINISTRATEUR',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this);
|
||||
_loadOrganisations();
|
||||
}
|
||||
|
||||
static String _roleDisplayName(String code) {
|
||||
const labels = {
|
||||
'MEMBRE_ACTIF': 'Membre actif',
|
||||
'MEMBRE_SIMPLE': 'Membre',
|
||||
'ADMIN_ORGANISATION': 'Admin organisation',
|
||||
'SECRETAIRE': 'Secrétaire',
|
||||
'TRESORIER': 'Trésorier',
|
||||
'CONSULTANT': 'Consultant',
|
||||
'GESTIONNAIRE_RH': 'Gestionnaire RH',
|
||||
'SUPER_ADMINISTRATEUR': 'Super admin',
|
||||
};
|
||||
return labels[code] ?? code;
|
||||
}
|
||||
|
||||
Future<void> _loadOrganisations() async {
|
||||
if (_organisationsLoaded) return;
|
||||
try {
|
||||
final repo = sl<OrganizationRepository>();
|
||||
final list = await repo.getOrganizations(page: 0, size: 200);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_organisations = list;
|
||||
_organisationsLoaded = true;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _organisationsLoaded = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -226,7 +273,7 @@ class _AdvancedSearchPageState extends State<AdvancedSearchPage>
|
||||
controller: _emailController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Email',
|
||||
hintText: 'exemple@unionflow.com',
|
||||
hintText: 'email@domaine.org',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@@ -305,6 +352,74 @@ class _AdvancedSearchPageState extends State<AdvancedSearchPage>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Organisations (multi-select)
|
||||
Text(
|
||||
'Organisations',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_organisations.isEmpty && !_organisationsLoaded
|
||||
? const SizedBox(
|
||||
height: 24,
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
)
|
||||
: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _organisations
|
||||
.where((o) => o.id != null && o.id!.isNotEmpty)
|
||||
.map((org) {
|
||||
final id = org.id!;
|
||||
final selected = _selectedOrganisations.contains(id);
|
||||
return FilterChip(
|
||||
label: Text(org.nomCourt ?? org.nom, overflow: TextOverflow.ellipsis, maxLines: 1),
|
||||
selected: selected,
|
||||
onSelected: (v) {
|
||||
setState(() {
|
||||
if (v) {
|
||||
_selectedOrganisations.add(id);
|
||||
} else {
|
||||
_selectedOrganisations.remove(id);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Rôles (multi-select)
|
||||
Text(
|
||||
'Rôles',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _searchRoleCodes.map((code) {
|
||||
final selected = _selectedRoles.contains(code);
|
||||
return FilterChip(
|
||||
label: Text(_roleDisplayName(code)),
|
||||
selected: selected,
|
||||
onSelected: (v) {
|
||||
setState(() {
|
||||
if (v) {
|
||||
_selectedRoles.add(code);
|
||||
} else {
|
||||
_selectedRoles.remove(code);
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Options booléennes
|
||||
CheckboxListTile(
|
||||
title: const Text('Inclure les membres inactifs'),
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../../shared/design_system/unionflow_design_system.dart';
|
||||
import '../../../../features/authentication/presentation/bloc/auth_bloc.dart';
|
||||
import '../../../../features/authentication/data/models/user_role.dart';
|
||||
import '../../../adhesions/presentation/pages/adhesions_page_wrapper.dart';
|
||||
|
||||
/// Page de gestion des membres - Interface sophistiquée et exhaustive
|
||||
///
|
||||
@@ -1131,12 +1133,23 @@ class _MembersPageState extends State<MembersPage> with TickerProviderStateMixin
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Ajouter un membre'),
|
||||
content: const Text('Fonctionnalité d\'ajout de membre à implémenter'),
|
||||
content: const Text(
|
||||
'Pour enregistrer un nouveau membre, créez une adhésion depuis le module Adhésions.',
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Fermer'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(builder: (_) => const AdhesionsPageWrapper()),
|
||||
);
|
||||
},
|
||||
child: const Text('Créer une adhésion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1144,10 +1157,35 @@ class _MembersPageState extends State<MembersPage> with TickerProviderStateMixin
|
||||
|
||||
/// Affiche les actions groupées
|
||||
void _showBulkActions() {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Actions groupées à implémenter'),
|
||||
backgroundColor: Color(0xFF6C5CE7),
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_download_outlined),
|
||||
title: const Text('Exporter la sélection'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
_exportMembers();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.email_outlined),
|
||||
title: const Text('Envoyer un message groupé'),
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Messagerie groupée à venir. Utilisez l\'action « Message » sur un membre.'),
|
||||
backgroundColor: Color(0xFF6C5CE7),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1190,25 +1228,10 @@ class _MembersPageState extends State<MembersPage> with TickerProviderStateMixin
|
||||
}
|
||||
}
|
||||
|
||||
/// Dialog d'édition de membre
|
||||
/// Dialog d'édition de membre : ouvre la fiche détail (édition complète à venir)
|
||||
void _showEditMemberDialog(Map<String, dynamic> member) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text('Modifier ${member['name']}'),
|
||||
content: const Text('Fonctionnalité de modification à implémenter'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Sauvegarder'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
Navigator.of(context).pop(); // ferme le dialog éventuel
|
||||
_showMemberDetails(member);
|
||||
}
|
||||
|
||||
/// Envoie un message à un membre
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ import '../../bloc/membres_bloc.dart';
|
||||
import '../../bloc/membres_event.dart';
|
||||
import '../../bloc/membres_state.dart';
|
||||
import '../../data/models/membre_complete_model.dart';
|
||||
import '../widgets/add_member_dialog.dart';
|
||||
import 'members_page_connected.dart';
|
||||
|
||||
final _getIt = GetIt.instance;
|
||||
@@ -50,6 +51,11 @@ class MembersPageConnected extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return BlocListener<MembresBloc, MembresState>(
|
||||
listener: (context, state) {
|
||||
// Après création : recharger la liste
|
||||
if (state is MembreCreated) {
|
||||
context.read<MembresBloc>().add(const LoadMembres(refresh: true));
|
||||
}
|
||||
|
||||
// Gestion des erreurs avec SnackBar
|
||||
if (state is MembresError) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
@@ -67,12 +73,6 @@ class MembersPageConnected extends StatelessWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Message de succès après création
|
||||
if (state is MembresLoaded && state.membres.isNotEmpty) {
|
||||
// Note: On pourrait ajouter un flag dans le state pour savoir si c'est après une création
|
||||
// Pour l'instant, on ne fait rien ici
|
||||
}
|
||||
},
|
||||
child: BlocBuilder<MembresBloc, MembresState>(
|
||||
builder: (context, state) {
|
||||
@@ -109,6 +109,16 @@ class MembersPageConnected extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Après création : on recharge la liste (listener a dispatché LoadMembres)
|
||||
if (state is MembreCreated) {
|
||||
return Container(
|
||||
color: const Color(0xFFF8F9FA),
|
||||
child: const Center(
|
||||
child: AppLoadingWidget(message: 'Actualisation...'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// État chargé avec succès
|
||||
if (state is MembresLoaded) {
|
||||
final membres = state.membres;
|
||||
@@ -122,14 +132,23 @@ class MembersPageConnected extends StatelessWidget {
|
||||
totalCount: state.totalElements,
|
||||
currentPage: state.currentPage,
|
||||
totalPages: state.totalPages,
|
||||
onPageChanged: (newPage) {
|
||||
onPageChanged: (newPage, recherche) {
|
||||
AppLogger.userAction('Load page', data: {'page': newPage});
|
||||
context.read<MembresBloc>().add(LoadMembres(page: newPage));
|
||||
context.read<MembresBloc>().add(LoadMembres(page: newPage, recherche: recherche));
|
||||
},
|
||||
onRefresh: () {
|
||||
AppLogger.userAction('Refresh membres');
|
||||
context.read<MembresBloc>().add(const LoadMembres(refresh: true));
|
||||
},
|
||||
onSearch: (query) {
|
||||
context.read<MembresBloc>().add(LoadMembres(page: 0, recherche: query));
|
||||
},
|
||||
onAddMember: () async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const AddMemberDialog(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,10 +214,10 @@ class MembersPageConnected extends StatelessWidget {
|
||||
'phone': membre.telephone ?? '',
|
||||
'department': membre.profession ?? '',
|
||||
'location': '${membre.ville ?? ''}, ${membre.pays ?? ''}',
|
||||
'permissions': 15, // Calcul permissions non implémenté côté backend
|
||||
'contributionScore': 0, // Pas de champ backend correspondant
|
||||
'permissions': 15, // Valeurs par défaut tant que l'API ne fournit pas permissions
|
||||
'contributionScore': 0, // Valeurs par défaut tant que l'API ne fournit pas contributionScore
|
||||
'eventsAttended': membre.nombreEvenementsParticipes,
|
||||
'projectsInvolved': 0, // Pas de champ backend correspondant
|
||||
'projectsInvolved': 0, // Valeurs par défaut tant que l'API ne fournit pas projectsInvolved
|
||||
|
||||
// Champs supplémentaires du modèle
|
||||
'prenom': membre.prenom,
|
||||
|
||||
Reference in New Issue
Block a user