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,17 +1,45 @@
/// BLoC pour la gestion des organisations
/// BLoC pour la gestion des organisations (Clean Architecture)
library organizations_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../data/models/organization_model.dart';
import '../data/services/organization_service.dart';
import '../domain/usecases/get_organizations.dart';
import '../domain/usecases/get_organization_by_id.dart';
import '../domain/usecases/create_organization.dart' as uc;
import '../domain/usecases/update_organization.dart' as uc;
import '../domain/usecases/delete_organization.dart' as uc;
import '../domain/usecases/get_organization_members.dart';
import '../domain/usecases/update_organization_config.dart';
import '../domain/repositories/organization_repository.dart';
import 'organizations_event.dart';
import 'organizations_state.dart';
/// BLoC principal pour la gestion des organisations
@injectable
class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
final OrganizationService _organizationService;
final GetOrganizations _getOrganizations;
final GetOrganizationById _getOrganizationById;
final uc.CreateOrganization _createOrganization;
final uc.UpdateOrganization _updateOrganization;
final uc.DeleteOrganization _deleteOrganization;
final GetOrganizationMembers _getOrganizationMembers;
final UpdateOrganizationConfig _updateOrganizationConfig;
final IOrganizationRepository _repository; // Pour méthodes non-couvertes (activate, suspend, search, stats)
final OrganizationService _organizationService; // Pour helpers (sort, filter local)
OrganizationsBloc(this._organizationService) : super(const OrganizationsInitial()) {
OrganizationsBloc(
this._getOrganizations,
this._getOrganizationById,
this._createOrganization,
this._updateOrganization,
this._deleteOrganization,
this._getOrganizationMembers,
this._updateOrganizationConfig,
this._repository,
this._organizationService,
) : super(const OrganizationsInitial()) {
// Enregistrement des handlers d'événements
on<LoadOrganizations>(_onLoadOrganizations);
on<LoadMoreOrganizations>(_onLoadMoreOrganizations);
@@ -42,18 +70,39 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(const OrganizationsLoading());
}
final organizations = await _organizationService.getOrganizations(
page: event.page,
size: event.size,
recherche: event.recherche,
);
List<OrganizationModel> listToUse;
List<String>? filterIds;
if (event.useMesOnly) {
// Admin d'organisation : endpoint backend /mes retourne uniquement ses organisations
listToUse = await _repository.getMesOrganisations();
filterIds = listToUse.map((o) => o.id).whereType<String>().toList();
} else {
final organizations = await _getOrganizations(
page: event.page,
size: event.size,
recherche: event.recherche,
);
if (event.filterOrganizationIds != null &&
event.filterOrganizationIds!.isNotEmpty) {
final allowedIds = event.filterOrganizationIds!.toSet();
listToUse = organizations
.where((org) => org.id != null && allowedIds.contains(org.id))
.toList();
filterIds = event.filterOrganizationIds;
} else {
listToUse = organizations;
filterIds = null;
}
}
emit(OrganizationsLoaded(
organizations: organizations,
filteredOrganizations: organizations,
hasReachedMax: organizations.length < event.size,
organizations: listToUse,
filteredOrganizations: listToUse,
hasReachedMax: event.useMesOnly || listToUse.length < event.size,
currentPage: event.page,
currentSearch: event.recherche,
filterOrganizationIds: filterIds,
useMesOnly: event.useMesOnly,
));
} catch (e) {
emit(OrganizationsError(
@@ -77,13 +126,21 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
try {
final nextPage = currentState.currentPage + 1;
final newOrganizations = await _organizationService.getOrganizations(
final newOrganizations = await _getOrganizations(
page: nextPage,
size: 20,
recherche: currentState.currentSearch,
);
final allOrganizations = [...currentState.organizations, ...newOrganizations];
var allOrganizations = [...currentState.organizations, ...newOrganizations];
// Réappliquer le filtre orgAdmin si présent
if (currentState.filterOrganizationIds != null &&
currentState.filterOrganizationIds!.isNotEmpty) {
final allowedIds = currentState.filterOrganizationIds!.toSet();
allOrganizations = allOrganizations
.where((org) => org.id != null && allowedIds.contains(org.id))
.toList();
}
final filteredOrganizations = _applyCurrentFilters(allOrganizations, currentState);
emit(currentState.copyWith(
@@ -137,7 +194,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
));
// Puis recherche serveur pour plus de résultats
final serverResults = await _organizationService.getOrganizations(
final serverResults = await _getOrganizations(
page: 0,
size: 50,
recherche: event.query,
@@ -169,7 +226,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(const OrganizationsLoading());
try {
final organizations = await _organizationService.searchOrganizations(
final organizations = await _repository.searchOrganizations(
nom: event.nom,
type: event.type,
statut: event.statut,
@@ -204,7 +261,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(OrganizationLoading(event.id));
try {
final organization = await _organizationService.getOrganizationById(event.id);
final organization = await _getOrganizationById(event.id);
if (organization != null) {
emit(OrganizationLoaded(organization));
} else {
@@ -226,7 +283,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(const OrganizationCreating());
try {
final createdOrganization = await _organizationService.createOrganization(event.organization);
final createdOrganization = await _createOrganization(event.organization);
emit(OrganizationCreated(createdOrganization));
// Recharger la liste si elle était déjà chargée
@@ -249,7 +306,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(OrganizationUpdating(event.id));
try {
final updatedOrganization = await _organizationService.updateOrganization(
final updatedOrganization = await _updateOrganization(
event.id,
event.organization,
);
@@ -284,7 +341,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(OrganizationDeleting(event.id));
try {
await _organizationService.deleteOrganization(event.id);
await _deleteOrganization(event.id);
emit(OrganizationDeleted(event.id));
// Retirer de la liste si elle était déjà chargée
@@ -313,7 +370,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(OrganizationActivating(event.id));
try {
final activatedOrganization = await _organizationService.activateOrganization(event.id);
final activatedOrganization = await _repository.activateOrganization(event.id);
emit(OrganizationActivated(activatedOrganization));
// Mettre à jour la liste si elle était déjà chargée
@@ -345,7 +402,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(OrganizationSuspending(event.id));
try {
final suspendedOrganization = await _organizationService.suspendOrganization(event.id);
final suspendedOrganization = await _repository.suspendOrganization(event.id);
emit(OrganizationSuspended(suspendedOrganization));
// Mettre à jour la liste si elle était déjà chargée
@@ -454,7 +511,7 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
emit(const OrganizationsStatsLoading());
try {
final stats = await _organizationService.getOrganizationsStats();
final stats = await _repository.getOrganizationsStats();
emit(OrganizationsStatsLoaded(stats));
} catch (e) {
emit(const OrganizationsStatsError('Erreur lors du chargement des statistiques'));
@@ -483,7 +540,15 @@ class OrganizationsBloc extends Bloc<OrganizationsEvent, OrganizationsState> {
RefreshOrganizations event,
Emitter<OrganizationsState> emit,
) {
add(const LoadOrganizations(refresh: true));
final currentState = state;
if (currentState is OrganizationsLoaded && currentState.useMesOnly) {
add(const LoadOrganizations(refresh: true, useMesOnly: true));
} else {
final filterIds = currentState is OrganizationsLoaded
? currentState.filterOrganizationIds
: null;
add(LoadOrganizations(refresh: true, filterOrganizationIds: filterIds));
}
}
/// Remet à zéro l'état

View File

@@ -18,16 +18,22 @@ class LoadOrganizations extends OrganizationsEvent {
final int size;
final String? recherche;
final bool refresh;
/// Filtre par IDs d'organisations (ex. pour orgAdmin : uniquement son organisation)
final List<String>? filterOrganizationIds;
/// Si true, appelle GET /api/organisations/mes (uniquement les orgs du membre connecté)
final bool useMesOnly;
const LoadOrganizations({
this.page = 0,
this.size = 20,
this.recherche,
this.refresh = false,
this.filterOrganizationIds,
this.useMesOnly = false,
});
@override
List<Object?> get props => [page, size, recherche, refresh];
List<Object?> get props => [page, size, recherche, refresh, filterOrganizationIds, useMesOnly];
}
/// Événement pour charger plus d'organisations (pagination)

View File

@@ -44,6 +44,10 @@ class OrganizationsLoaded extends OrganizationsState {
final OrganizationSortType? sortType;
final bool sortAscending;
final Map<String, dynamic>? stats;
/// Filtre appliqué (ex. orgAdmin : uniquement ses organisations)
final List<String>? filterOrganizationIds;
/// True si la liste provient de GET /mes (admin d'organisation)
final bool useMesOnly;
const OrganizationsLoaded({
required this.organizations,
@@ -56,6 +60,8 @@ class OrganizationsLoaded extends OrganizationsState {
this.sortType,
this.sortAscending = true,
this.stats,
this.filterOrganizationIds,
this.useMesOnly = false,
});
/// Copie avec modifications
@@ -70,6 +76,8 @@ class OrganizationsLoaded extends OrganizationsState {
OrganizationSortType? sortType,
bool? sortAscending,
Map<String, dynamic>? stats,
List<String>? filterOrganizationIds,
bool? useMesOnly,
bool clearSearch = false,
bool clearStatusFilter = false,
bool clearTypeFilter = false,
@@ -86,6 +94,8 @@ class OrganizationsLoaded extends OrganizationsState {
sortType: clearSort ? null : (sortType ?? this.sortType),
sortAscending: sortAscending ?? this.sortAscending,
stats: stats ?? this.stats,
filterOrganizationIds: filterOrganizationIds ?? this.filterOrganizationIds,
useMesOnly: useMesOnly ?? this.useMesOnly,
);
}
@@ -130,6 +140,8 @@ class OrganizationsLoaded extends OrganizationsState {
sortType,
sortAscending,
stats,
filterOrganizationIds,
useMesOnly,
];
}