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:
dahoud
2026-03-15 02:12:17 +00:00
parent bbc409de9d
commit e8ad874015
635 changed files with 58160 additions and 20674 deletions

View File

@@ -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'),

View File

@@ -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

View File

@@ -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,