Initial commit: unionflow-mobile-apps

Application Flutter complète (sans build artifacts).

Signed-off-by: lions dev Team
This commit is contained in:
dahoud
2026-03-15 16:30:08 +00:00
commit d094d6db9c
1790 changed files with 507435 additions and 0 deletions

View File

@@ -0,0 +1,326 @@
# Finance Workflow Feature
## Vue d'ensemble
Module complet de workflow financier avec approbations multi-niveaux et gestion budgétaire pour UnionFlow.
## Architecture Clean Architecture + BLoC
```
finance_workflow/
├── domain/ # Couche métier (entités, repositories interfaces, use cases)
│ ├── entities/
│ │ ├── transaction_approval.dart # Entité approbation avec statuts
│ │ ├── budget.dart # Entité budget avec lignes
│ │ └── financial_audit_log.dart # Entité audit trail
│ ├── repositories/
│ │ └── finance_workflow_repository.dart # Interface repository
│ └── usecases/
│ ├── get_pending_approvals.dart
│ ├── get_approval_by_id.dart
│ ├── approve_transaction.dart
│ ├── reject_transaction.dart
│ ├── get_budgets.dart
│ ├── get_budget_by_id.dart
│ ├── create_budget.dart
│ └── get_budget_tracking.dart
├── data/ # Couche données (models, datasources, repository impl)
│ ├── models/
│ │ ├── transaction_approval_model.dart
│ │ ├── transaction_approval_model.g.dart
│ │ ├── budget_model.dart
│ │ └── budget_model.g.dart
│ ├── datasources/
│ │ └── finance_workflow_remote_datasource.dart
│ └── repositories/
│ └── finance_workflow_repository_impl.dart
└── presentation/ # Couche présentation (BLoC, pages, widgets)
├── bloc/
│ ├── approval_bloc.dart
│ ├── approval_event.dart
│ ├── approval_state.dart
│ ├── budget_bloc.dart
│ ├── budget_event.dart
│ └── budget_state.dart
├── pages/
│ ├── pending_approvals_page.dart
│ └── budgets_list_page.dart
└── widgets/
├── approve_dialog.dart
└── reject_dialog.dart
```
## Fonctionnalités
### 1. Approbations de Transactions
#### Statuts d'approbation
- `pending` : En attente d'approbation
- `approved` : Approuvée (niveau 1)
- `validated` : Validée (niveau 2 - validation finale)
- `rejected` : Rejetée
- `expired` : Expirée (timeout)
- `cancelled` : Annulée
#### Niveaux d'approbation (selon montant)
- `none` : Aucune approbation requise (< seuil)
- `level1` : Un approbateur requis
- `level2` : Deux approbateurs requis
- `level3` : Trois approbateurs requis (montants très élevés)
#### Types de transactions
- `contribution` : Cotisation/contribution
- `deposit` : Dépôt épargne
- `withdrawal` : Retrait épargne
- `transfer` : Transfert
- `solidarity` : Dépense solidarité
- `event` : Dépense événement
- `other` : Autre dépense
#### Cas d'usage implémentés
1. **Consulter les approbations en attente** : Liste filtrée par organisation
2. **Voir détail d'une approbation** : Informations complètes avec historique
3. **Approuver une transaction** : Avec commentaire optionnel
4. **Rejeter une transaction** : Avec raison obligatoire (min 10 caractères)
5. **Rafraîchir la liste** : Pull-to-refresh
#### UI/UX
- Liste des approbations avec filtres
- Card d'approbation affichant :
- Type de transaction avec badge coloré selon niveau
- Montant en devise locale (XOF par défaut)
- Demandeur et date de création
- Progression des approbations (X/Y)
- Boutons Approuver/Rejeter
- Dialog d'approbation avec récapitulatif et champ commentaire
- Dialog de rejet avec validation de raison (min 10 chars)
- États : Loading, Empty, Error avec retry
- Notifications Snackbar pour succès/erreur
### 2. Gestion des Budgets
#### Périodes budgétaires
- `monthly` : Budget mensuel
- `quarterly` : Budget trimestriel
- `semiannual` : Budget semestriel
- `annual` : Budget annuel
#### Statuts de budget
- `draft` : Brouillon (en cours de création)
- `active` : Actif
- `closed` : Clos (période terminée)
- `cancelled` : Annulé
#### Catégories budgétaires
- `contributions` : Cotisations/contributions
- `savings` : Épargne
- `solidarity` : Solidarité
- `events` : Événements
- `operational` : Fonctionnement (frais généraux)
- `investments` : Investissements
- `other` : Autres
#### Cas d'usage implémentés
1. **Consulter les budgets** : Liste avec filtres (statut, année)
2. **Voir détail d'un budget** : Informations complètes avec lignes
3. **Créer un budget** : Avec validation (nom, période, lignes)
4. **Suivre l'exécution budgétaire** : Taux de réalisation, écart
5. **Filtrer les budgets** : Par statut et année
#### UI/UX
- Liste des budgets avec filtres avancés
- Card de budget affichant :
- Nom et période (Mensuel/Annuel YYYY)
- Statut avec badge coloré
- Montant prévu vs réalisé
- Barre de progression avec taux de réalisation
- Indicateur de dépassement (rouge si > 100%)
- Dialog de filtres avec chips (statut + année)
- Chips de filtres actifs supprimables
- FAB pour créer un nouveau budget
- États : Loading, Empty, Error avec retry
### 3. Audit Trail
#### Types d'opérations auditées
- `create`, `read`, `update`, `delete`
- `approve`, `reject`, `validate`, `cancel`
- `export`
#### Types d'entités auditées
- `contribution`, `savingsTransaction`, `approval`, `budget`
#### Niveaux de sévérité
- `info` : Information
- `warning` : Avertissement
- `error` : Erreur
- `critical` : Critique
#### Données capturées
- Utilisateur, rôle, IP, user agent
- Date/heure de l'opération
- Entité et ID de l'entité
- Données avant/après (JSON)
- Montants impliqués
## API Endpoints
### Approbations
- `GET /api/finance/approvals/pending?organizationId={id}` : Liste des approbations en attente
- `GET /api/finance/approvals/{approvalId}` : Détail d'une approbation
- `POST /api/finance/approvals/{approvalId}/approve` : Approuver (body: {comment?})
- `POST /api/finance/approvals/{approvalId}/reject` : Rejeter (body: {reason})
### Budgets
- `GET /api/finance/budgets?organizationId={id}&status={status}&year={year}` : Liste des budgets
- `GET /api/finance/budgets/{budgetId}` : Détail d'un budget
- `POST /api/finance/budgets` : Créer un budget
- `GET /api/finance/budgets/{budgetId}/tracking` : Suivi budgétaire
## Permissions RBAC
### OrgAdmin + SuperAdmin
- ✅ Consulter toutes les approbations de l'organisation
- ✅ Approuver/Rejeter les transactions
- ✅ Consulter tous les budgets de l'organisation
- ✅ Créer/Modifier les budgets
- ✅ Accéder aux logs d'audit
### Autres rôles
- ❌ Pas d'accès au workflow financier (gap P0 identifié dans audit métier)
## État d'implémentation
### ✅ Terminé (Mobile)
- [x] Entities (TransactionApproval, Budget, FinancialAuditLog)
- [x] Repository interface
- [x] Data models avec JSON serialization (custom @JsonKey pour nested types)
- [x] Remote datasource (8 endpoints API)
- [x] Repository implementation avec gestion d'erreurs
- [x] 8 Use cases avec validation
- [x] 2 BLoCs complets (Approval, Budget) avec états/événements
- [x] 2 Pages fonctionnelles (Pending Approvals, Budgets List)
- [x] 2 Dialogs (Approve, Reject) avec validation
- [x] Integration navigation (routes + menu RBAC)
- [x] Build runner successful (génération .g.dart)
### ⏳ En cours
- [ ] Budget detail page (voir détail + tracking)
- [ ] Create budget page (formulaire création avec lignes)
- [ ] Audit logs page (consultation logs d'audit)
### 📋 À faire (Backend Quarkus)
- [ ] POST /api/finance/approvals/pending (endpoint backend)
- [ ] POST /api/finance/approvals/{id}/approve (endpoint backend)
- [ ] POST /api/finance/approvals/{id}/reject (endpoint backend)
- [ ] GET /api/finance/budgets (endpoint backend)
- [ ] POST /api/finance/budgets (endpoint backend)
- [ ] GET /api/finance/budgets/{id}/tracking (endpoint backend)
- [ ] Audit log persistence et endpoints
- [ ] Tests unitaires (use cases, BLoCs)
- [ ] Tests d'intégration (API)
## Patterns techniques
### JSON Serialization pour types nested
Fix appliqué pour les listes de types custom (ApproverAction, BudgetLine) :
```dart
@JsonSerializable(explicitToJson: true)
class TransactionApprovalModel extends TransactionApproval {
@JsonKey(
fromJson: _approversFromJson,
toJson: _approversToJson,
)
@override
final List<ApproverAction> approvers;
const TransactionApprovalModel({
// ... autres params
this.approvers = const [],
// ... autres params
}) : super(approvers: approvers);
static List<ApproverAction> _approversFromJson(List<dynamic>? json) =>
json?.map((e) => ApproverActionModel.fromJson(e as Map<String, dynamic>)).toList() ?? [];
static List<Map<String, dynamic>> _approversToJson(List<ApproverAction>? approvers) =>
approvers?.map((a) => ApproverActionModel(
approverId: a.approverId,
approverName: a.approverName,
approverRole: a.approverRole,
decision: a.decision,
comment: a.comment,
decidedAt: a.decidedAt,
).toJson()).toList() ?? [];
}
```
### Gestion d'erreurs avec Either
```dart
final result = await approveTransaction(approvalId: id);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(TransactionApproved(approval: approval)),
);
```
### BLoC Pattern
```dart
@injectable
class ApprovalBloc extends Bloc<ApprovalEvent, ApprovalState> {
final GetPendingApprovals getPendingApprovals;
final ApproveTransaction approveTransaction;
final RejectTransaction rejectTransaction;
ApprovalBloc({
required this.getPendingApprovals,
required this.approveTransaction,
required this.rejectTransaction,
}) : super(const ApprovalInitial()) {
on<LoadPendingApprovals>(_onLoadPendingApprovals);
on<ApproveTransactionEvent>(_onApproveTransaction);
on<RejectTransactionEvent>(_onRejectTransaction);
}
}
```
## Dépendances
Déjà présentes dans `pubspec.yaml` :
- `flutter_bloc: ^8.1.6`
- `injectable: ^2.4.4`
- `get_it: ^8.0.2`
- `dartz: ^0.10.1`
- `equatable: ^2.0.7`
- `json_annotation: ^4.9.0`
- `http: ^1.2.2`
- `flutter_secure_storage: ^9.2.2`
- `intl: ^0.19.0`
## Notes techniques
1. **Custom JSON serialization** requise pour `List<ApproverAction>` et `List<BudgetLine>` (types nested)
2. **Network check** avant chaque appel API (NetworkInfo)
3. **Error mapping** : Exceptions → Failures (ValidationFailure, NetworkFailure, ServerFailure, etc.)
4. **RBAC check** dans navigation (OrgAdmin/SuperAdmin uniquement)
5. **Pull-to-refresh** sur toutes les listes
6. **Snackbar notifications** pour succès/erreur
7. **Form validation** sur reject dialog (raison min 10 chars)
## Prochaines étapes
1. Créer Budget Detail Page avec suivi détaillé par ligne
2. Créer Create Budget Page avec formulaire multi-steps
3. Créer Audit Logs Page avec filtres avancés
4. Implémenter endpoints backend Quarkus
5. Ajouter tests unitaires pour use cases et BLoCs
6. Ajouter tests d'intégration API
7. Optimiser avec caching (budget actif, approvals count)
8. Ajouter notifications push pour approbations urgentes

View File

@@ -0,0 +1,229 @@
/// Datasource distant pour le workflow financier (API)
library finance_workflow_remote_datasource;
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/config/environment.dart';
import '../../../../core/error/exceptions.dart';
import '../models/transaction_approval_model.dart';
import '../models/budget_model.dart';
import '../../domain/entities/transaction_approval.dart';
import '../../domain/entities/budget.dart';
@lazySingleton
class FinanceWorkflowRemoteDatasource {
final http.Client client;
final FlutterSecureStorage secureStorage;
FinanceWorkflowRemoteDatasource({
required this.client,
required this.secureStorage,
});
/// Headers HTTP avec authentification
Future<Map<String, String>> _getHeaders() async {
final token = await secureStorage.read(key: 'access_token');
return {
'Content-Type': 'application/json',
'Accept': 'application/json',
if (token != null) 'Authorization': 'Bearer $token',
};
}
// === APPROBATIONS ===
Future<List<TransactionApprovalModel>> getPendingApprovals({
String? organizationId,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/finance/approvals/pending')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList
.map((json) => TransactionApprovalModel.fromJson(json))
.toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des approbations');
}
}
Future<TransactionApprovalModel> getApprovalById(String approvalId) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/finance/approvals/$approvalId');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return TransactionApprovalModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Approbation non trouvée');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération de l\'approbation');
}
}
Future<TransactionApprovalModel> approveTransaction({
required String approvalId,
String? comment,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/finance/approvals/$approvalId/approve');
final body = json.encode({
if (comment != null) 'comment': comment,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 200) {
return TransactionApprovalModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour approuver');
} else {
throw ServerException('Erreur lors de l\'approbation');
}
}
Future<TransactionApprovalModel> rejectTransaction({
required String approvalId,
required String reason,
}) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/finance/approvals/$approvalId/reject');
final body = json.encode({
'reason': reason,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 200) {
return TransactionApprovalModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour rejeter');
} else {
throw ServerException('Erreur lors du rejet');
}
}
// === BUDGETS ===
Future<List<BudgetModel>> getBudgets({
String? organizationId,
String? status,
int? year,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/finance/budgets')
.replace(queryParameters: {
if (organizationId != null) 'organizationId': organizationId,
if (status != null) 'status': status,
if (year != null) 'year': year.toString(),
});
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
final List<dynamic> jsonList = json.decode(response.body);
return jsonList.map((json) => BudgetModel.fromJson(json)).toList();
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération des budgets');
}
}
Future<BudgetModel> getBudgetById(String budgetId) async {
final uri =
Uri.parse('${AppConfig.apiBaseUrl}/api/finance/budgets/$budgetId');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return BudgetModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 404) {
throw NotFoundException('Budget non trouvé');
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération du budget');
}
}
Future<BudgetModel> createBudget({
required String name,
String? description,
required String organizationId,
required String period,
required int year,
int? month,
required List<Map<String, dynamic>> lines,
}) async {
final uri = Uri.parse('${AppConfig.apiBaseUrl}/api/finance/budgets');
final body = json.encode({
'name': name,
if (description != null) 'description': description,
'organizationId': organizationId,
'period': period,
'year': year,
if (month != null) 'month': month,
'lines': lines,
});
final response = await client.post(
uri,
headers: await _getHeaders(),
body: body,
);
if (response.statusCode == 201 || response.statusCode == 200) {
return BudgetModel.fromJson(json.decode(response.body));
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else if (response.statusCode == 403) {
throw ForbiddenException('Permission insuffisante pour créer un budget');
} else {
throw ServerException('Erreur lors de la création du budget');
}
}
Future<Map<String, dynamic>> getBudgetTracking({
required String budgetId,
}) async {
final uri = Uri.parse(
'${AppConfig.apiBaseUrl}/api/finance/budgets/$budgetId/tracking');
final response = await client.get(uri, headers: await _getHeaders());
if (response.statusCode == 200) {
return json.decode(response.body) as Map<String, dynamic>;
} else if (response.statusCode == 401) {
throw UnauthorizedException();
} else {
throw ServerException('Erreur lors de la récupération du suivi budgétaire');
}
}
}

View File

@@ -0,0 +1,100 @@
/// Model de données Budget avec sérialisation JSON
library budget_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/budget.dart';
part 'budget_model.g.dart';
@JsonSerializable(explicitToJson: true)
class BudgetModel extends Budget {
@JsonKey(
fromJson: _linesFromJson,
toJson: _linesToJson,
)
@override
final List<BudgetLine> lines;
const BudgetModel({
required super.id,
required super.name,
super.description,
required super.organizationId,
required super.period,
required super.year,
super.month,
required super.status,
this.lines = const [],
required super.totalPlanned,
super.totalRealized,
super.currency,
required super.createdBy,
required super.createdAt,
super.approvedAt,
super.approvedBy,
required super.startDate,
required super.endDate,
super.metadata,
}) : super(lines: lines);
static List<BudgetLine> _linesFromJson(List<dynamic>? json) =>
json?.map((e) => BudgetLineModel.fromJson(e as Map<String, dynamic>)).toList() ?? [];
static List<Map<String, dynamic>> _linesToJson(List<BudgetLine>? lines) =>
lines?.map((l) => BudgetLineModel(
id: l.id,
category: l.category,
name: l.name,
description: l.description,
amountPlanned: l.amountPlanned,
amountRealized: l.amountRealized,
notes: l.notes,
).toJson()).toList() ?? [];
factory BudgetModel.fromJson(Map<String, dynamic> json) =>
_$BudgetModelFromJson(json);
Map<String, dynamic> toJson() => _$BudgetModelToJson(this);
factory BudgetModel.fromEntity(Budget entity) {
return BudgetModel(
id: entity.id,
name: entity.name,
description: entity.description,
organizationId: entity.organizationId,
period: entity.period,
year: entity.year,
month: entity.month,
status: entity.status,
lines: entity.lines,
totalPlanned: entity.totalPlanned,
totalRealized: entity.totalRealized,
currency: entity.currency,
createdBy: entity.createdBy,
createdAt: entity.createdAt,
approvedAt: entity.approvedAt,
approvedBy: entity.approvedBy,
startDate: entity.startDate,
endDate: entity.endDate,
metadata: entity.metadata,
);
}
}
@JsonSerializable()
class BudgetLineModel extends BudgetLine {
const BudgetLineModel({
required super.id,
required super.category,
required super.name,
super.description,
required super.amountPlanned,
super.amountRealized,
super.notes,
});
factory BudgetLineModel.fromJson(Map<String, dynamic> json) =>
_$BudgetLineModelFromJson(json);
Map<String, dynamic> toJson() => _$BudgetLineModelToJson(this);
}

View File

@@ -0,0 +1,102 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'budget_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
BudgetModel _$BudgetModelFromJson(Map<String, dynamic> json) => BudgetModel(
id: json['id'] as String,
name: json['name'] as String,
description: json['description'] as String?,
organizationId: json['organizationId'] as String,
period: $enumDecode(_$BudgetPeriodEnumMap, json['period']),
year: (json['year'] as num).toInt(),
month: (json['month'] as num?)?.toInt(),
status: $enumDecode(_$BudgetStatusEnumMap, json['status']),
lines: json['lines'] == null
? const []
: BudgetModel._linesFromJson(json['lines'] as List?),
totalPlanned: (json['totalPlanned'] as num).toDouble(),
totalRealized: (json['totalRealized'] as num?)?.toDouble() ?? 0,
currency: json['currency'] as String? ?? 'XOF',
createdBy: json['createdBy'] as String,
createdAt: DateTime.parse(json['createdAt'] as String),
approvedAt: json['approvedAt'] == null
? null
: DateTime.parse(json['approvedAt'] as String),
approvedBy: json['approvedBy'] as String?,
startDate: DateTime.parse(json['startDate'] as String),
endDate: DateTime.parse(json['endDate'] as String),
metadata: json['metadata'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$BudgetModelToJson(BudgetModel instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'organizationId': instance.organizationId,
'period': _$BudgetPeriodEnumMap[instance.period]!,
'year': instance.year,
'month': instance.month,
'status': _$BudgetStatusEnumMap[instance.status]!,
'totalPlanned': instance.totalPlanned,
'totalRealized': instance.totalRealized,
'currency': instance.currency,
'createdBy': instance.createdBy,
'createdAt': instance.createdAt.toIso8601String(),
'approvedAt': instance.approvedAt?.toIso8601String(),
'approvedBy': instance.approvedBy,
'startDate': instance.startDate.toIso8601String(),
'endDate': instance.endDate.toIso8601String(),
'metadata': instance.metadata,
'lines': BudgetModel._linesToJson(instance.lines),
};
const _$BudgetPeriodEnumMap = {
BudgetPeriod.monthly: 'monthly',
BudgetPeriod.quarterly: 'quarterly',
BudgetPeriod.semiannual: 'semiannual',
BudgetPeriod.annual: 'annual',
};
const _$BudgetStatusEnumMap = {
BudgetStatus.draft: 'draft',
BudgetStatus.active: 'active',
BudgetStatus.closed: 'closed',
BudgetStatus.cancelled: 'cancelled',
};
BudgetLineModel _$BudgetLineModelFromJson(Map<String, dynamic> json) =>
BudgetLineModel(
id: json['id'] as String,
category: $enumDecode(_$BudgetCategoryEnumMap, json['category']),
name: json['name'] as String,
description: json['description'] as String?,
amountPlanned: (json['amountPlanned'] as num).toDouble(),
amountRealized: (json['amountRealized'] as num?)?.toDouble() ?? 0,
notes: json['notes'] as String?,
);
Map<String, dynamic> _$BudgetLineModelToJson(BudgetLineModel instance) =>
<String, dynamic>{
'id': instance.id,
'category': _$BudgetCategoryEnumMap[instance.category]!,
'name': instance.name,
'description': instance.description,
'amountPlanned': instance.amountPlanned,
'amountRealized': instance.amountRealized,
'notes': instance.notes,
};
const _$BudgetCategoryEnumMap = {
BudgetCategory.contributions: 'contributions',
BudgetCategory.savings: 'savings',
BudgetCategory.solidarity: 'solidarity',
BudgetCategory.events: 'events',
BudgetCategory.operational: 'operational',
BudgetCategory.investments: 'investments',
BudgetCategory.other: 'other',
};

View File

@@ -0,0 +1,92 @@
/// Model de données TransactionApproval avec sérialisation JSON
library transaction_approval_model;
import 'package:json_annotation/json_annotation.dart';
import '../../domain/entities/transaction_approval.dart';
part 'transaction_approval_model.g.dart';
@JsonSerializable(explicitToJson: true)
class TransactionApprovalModel extends TransactionApproval {
@JsonKey(
fromJson: _approversFromJson,
toJson: _approversToJson,
)
@override
final List<ApproverAction> approvers;
const TransactionApprovalModel({
required super.id,
required super.transactionId,
required super.transactionType,
required super.amount,
super.currency,
required super.requesterId,
required super.requesterName,
super.organizationId,
required super.requiredLevel,
required super.status,
this.approvers = const [],
super.rejectionReason,
required super.createdAt,
super.expiresAt,
super.completedAt,
super.metadata,
}) : super(approvers: approvers);
static List<ApproverAction> _approversFromJson(List<dynamic>? json) =>
json?.map((e) => ApproverActionModel.fromJson(e as Map<String, dynamic>)).toList() ?? [];
static List<Map<String, dynamic>> _approversToJson(List<ApproverAction>? approvers) =>
approvers?.map((a) => ApproverActionModel(
approverId: a.approverId,
approverName: a.approverName,
approverRole: a.approverRole,
decision: a.decision,
comment: a.comment,
decidedAt: a.decidedAt,
).toJson()).toList() ?? [];
factory TransactionApprovalModel.fromJson(Map<String, dynamic> json) =>
_$TransactionApprovalModelFromJson(json);
Map<String, dynamic> toJson() => _$TransactionApprovalModelToJson(this);
factory TransactionApprovalModel.fromEntity(TransactionApproval entity) {
return TransactionApprovalModel(
id: entity.id,
transactionId: entity.transactionId,
transactionType: entity.transactionType,
amount: entity.amount,
currency: entity.currency,
requesterId: entity.requesterId,
requesterName: entity.requesterName,
organizationId: entity.organizationId,
requiredLevel: entity.requiredLevel,
status: entity.status,
approvers: entity.approvers,
rejectionReason: entity.rejectionReason,
createdAt: entity.createdAt,
expiresAt: entity.expiresAt,
completedAt: entity.completedAt,
metadata: entity.metadata,
);
}
}
@JsonSerializable()
class ApproverActionModel extends ApproverAction {
const ApproverActionModel({
required super.approverId,
required super.approverName,
required super.approverRole,
required super.decision,
super.comment,
super.decidedAt,
});
factory ApproverActionModel.fromJson(Map<String, dynamic> json) =>
_$ApproverActionModelFromJson(json);
Map<String, dynamic> toJson() => _$ApproverActionModelToJson(this);
}

View File

@@ -0,0 +1,113 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'transaction_approval_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TransactionApprovalModel _$TransactionApprovalModelFromJson(
Map<String, dynamic> json) =>
TransactionApprovalModel(
id: json['id'] as String,
transactionId: json['transactionId'] as String,
transactionType:
$enumDecode(_$TransactionTypeEnumMap, json['transactionType']),
amount: (json['amount'] as num).toDouble(),
currency: json['currency'] as String? ?? 'XOF',
requesterId: json['requesterId'] as String,
requesterName: json['requesterName'] as String,
organizationId: json['organizationId'] as String?,
requiredLevel: $enumDecode(_$ApprovalLevelEnumMap, json['requiredLevel']),
status: $enumDecode(_$ApprovalStatusEnumMap, json['status']),
approvers: json['approvers'] == null
? const []
: TransactionApprovalModel._approversFromJson(
json['approvers'] as List?),
rejectionReason: json['rejectionReason'] as String?,
createdAt: DateTime.parse(json['createdAt'] as String),
expiresAt: json['expiresAt'] == null
? null
: DateTime.parse(json['expiresAt'] as String),
completedAt: json['completedAt'] == null
? null
: DateTime.parse(json['completedAt'] as String),
metadata: json['metadata'] as Map<String, dynamic>?,
);
Map<String, dynamic> _$TransactionApprovalModelToJson(
TransactionApprovalModel instance) =>
<String, dynamic>{
'id': instance.id,
'transactionId': instance.transactionId,
'transactionType': _$TransactionTypeEnumMap[instance.transactionType]!,
'amount': instance.amount,
'currency': instance.currency,
'requesterId': instance.requesterId,
'requesterName': instance.requesterName,
'organizationId': instance.organizationId,
'requiredLevel': _$ApprovalLevelEnumMap[instance.requiredLevel]!,
'status': _$ApprovalStatusEnumMap[instance.status]!,
'rejectionReason': instance.rejectionReason,
'createdAt': instance.createdAt.toIso8601String(),
'expiresAt': instance.expiresAt?.toIso8601String(),
'completedAt': instance.completedAt?.toIso8601String(),
'metadata': instance.metadata,
'approvers':
TransactionApprovalModel._approversToJson(instance.approvers),
};
const _$TransactionTypeEnumMap = {
TransactionType.contribution: 'contribution',
TransactionType.deposit: 'deposit',
TransactionType.withdrawal: 'withdrawal',
TransactionType.transfer: 'transfer',
TransactionType.solidarity: 'solidarity',
TransactionType.event: 'event',
TransactionType.other: 'other',
};
const _$ApprovalLevelEnumMap = {
ApprovalLevel.none: 'none',
ApprovalLevel.level1: 'level1',
ApprovalLevel.level2: 'level2',
ApprovalLevel.level3: 'level3',
};
const _$ApprovalStatusEnumMap = {
ApprovalStatus.pending: 'pending',
ApprovalStatus.approved: 'approved',
ApprovalStatus.validated: 'validated',
ApprovalStatus.rejected: 'rejected',
ApprovalStatus.expired: 'expired',
ApprovalStatus.cancelled: 'cancelled',
};
ApproverActionModel _$ApproverActionModelFromJson(Map<String, dynamic> json) =>
ApproverActionModel(
approverId: json['approverId'] as String,
approverName: json['approverName'] as String,
approverRole: json['approverRole'] as String,
decision: $enumDecode(_$ApprovalDecisionEnumMap, json['decision']),
comment: json['comment'] as String?,
decidedAt: json['decidedAt'] == null
? null
: DateTime.parse(json['decidedAt'] as String),
);
Map<String, dynamic> _$ApproverActionModelToJson(
ApproverActionModel instance) =>
<String, dynamic>{
'approverId': instance.approverId,
'approverName': instance.approverName,
'approverRole': instance.approverRole,
'decision': _$ApprovalDecisionEnumMap[instance.decision]!,
'comment': instance.comment,
'decidedAt': instance.decidedAt?.toIso8601String(),
};
const _$ApprovalDecisionEnumMap = {
ApprovalDecision.pending: 'pending',
ApprovalDecision.approved: 'approved',
ApprovalDecision.rejected: 'rejected',
};

View File

@@ -0,0 +1,413 @@
/// Implémentation du repository de workflow financier
library finance_workflow_repository_impl;
import 'dart:async';
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/exceptions.dart';
import '../../../../core/error/failures.dart';
import '../../../../core/network/network_info.dart';
import '../../../../core/network/retry_policy.dart';
import '../../../../core/network/offline_manager.dart';
import '../../domain/entities/budget.dart';
import '../../domain/entities/financial_audit_log.dart';
import '../../domain/entities/transaction_approval.dart';
import '../../domain/repositories/finance_workflow_repository.dart';
import '../datasources/finance_workflow_remote_datasource.dart';
@LazySingleton(as: FinanceWorkflowRepository)
class FinanceWorkflowRepositoryImpl implements FinanceWorkflowRepository {
final FinanceWorkflowRemoteDatasource remoteDatasource;
final NetworkInfo networkInfo;
final OfflineManager offlineManager;
final RetryPolicy _retryPolicy;
FinanceWorkflowRepositoryImpl({
required this.remoteDatasource,
required this.networkInfo,
required this.offlineManager,
}) : _retryPolicy = RetryPolicy(config: RetryConfig.standard);
@override
Future<Either<Failure, List<TransactionApproval>>> getPendingApprovals({
String? organizationId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final approvals = await _retryPolicy.execute(
operation: () => remoteDatasource.getPendingApprovals(
organizationId: organizationId,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approvals);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, TransactionApproval>> getApprovalById(
String approvalId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final approval = await _retryPolicy.execute(
operation: () => remoteDatasource.getApprovalById(approvalId),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approval);
} on NotFoundException {
return Left(NotFoundFailure('Approbation non trouvée'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, TransactionApproval>> approveTransaction({
required String approvalId,
String? comment,
}) async {
if (!await networkInfo.isConnected) {
// Queue for retry when back online
await offlineManager.queueOperation(
operationType: 'approveTransaction',
endpoint: '/api/finance/approvals/$approvalId/approve',
data: {'approvalId': approvalId, 'comment': comment},
);
return Left(NetworkFailure('Pas de connexion Internet. Opération mise en attente.'));
}
try {
final approval = await _retryPolicy.execute(
operation: () => remoteDatasource.approveTransaction(
approvalId: approvalId,
comment: comment,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approval);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, TransactionApproval>> rejectTransaction({
required String approvalId,
required String reason,
}) async {
if (!await networkInfo.isConnected) {
// Queue for retry when back online
await offlineManager.queueOperation(
operationType: 'rejectTransaction',
endpoint: '/api/finance/approvals/$approvalId/reject',
data: {'approvalId': approvalId, 'reason': reason},
);
return Left(NetworkFailure('Pas de connexion Internet. Opération mise en attente.'));
}
try {
final approval = await _retryPolicy.execute(
operation: () => remoteDatasource.rejectTransaction(
approvalId: approvalId,
reason: reason,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(approval);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, List<Budget>>> getBudgets({
String? organizationId,
BudgetStatus? status,
int? year,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final budgets = await _retryPolicy.execute(
operation: () => remoteDatasource.getBudgets(
organizationId: organizationId,
status: status?.name,
year: year,
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(budgets);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Budget>> getBudgetById(String budgetId) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final budget = await _retryPolicy.execute(
operation: () => remoteDatasource.getBudgetById(budgetId),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(budget);
} on NotFoundException {
return Left(NotFoundFailure('Budget non trouvé'));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Budget>> createBudget({
required String name,
String? description,
required String organizationId,
required BudgetPeriod period,
required int year,
int? month,
required List<BudgetLine> lines,
}) async {
if (!await networkInfo.isConnected) {
// Queue for retry when back online
await offlineManager.queueOperation(
operationType: 'createBudget',
endpoint: '/api/finance/budgets',
data: {
'name': name,
'description': description,
'organizationId': organizationId,
'period': period.name,
'year': year,
'month': month,
'lines': lines.map((line) => {
'id': line.id,
'category': line.category.name,
'name': line.name,
'description': line.description,
'amountPlanned': line.amountPlanned,
'amountRealized': line.amountRealized,
'notes': line.notes,
}).toList(),
},
);
return Left(NetworkFailure('Pas de connexion Internet. Opération mise en attente.'));
}
try {
final budget = await _retryPolicy.execute(
operation: () => remoteDatasource.createBudget(
name: name,
description: description,
organizationId: organizationId,
period: period.name,
year: year,
month: month,
lines: lines.map((line) => {
'id': line.id,
'category': line.category.name,
'name': line.name,
'description': line.description,
'amountPlanned': line.amountPlanned,
'amountRealized': line.amountRealized,
'notes': line.notes,
}).toList(),
),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(budget);
} on ForbiddenException catch (e) {
return Left(ForbiddenFailure(e.message));
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
@override
Future<Either<Failure, Map<String, dynamic>>> getBudgetTracking({
required String budgetId,
}) async {
if (!await networkInfo.isConnected) {
return Left(NetworkFailure('Pas de connexion Internet'));
}
try {
final tracking = await _retryPolicy.execute(
operation: () => remoteDatasource.getBudgetTracking(budgetId: budgetId),
shouldRetry: (error) => _isRetryableError(error),
);
return Right(tracking);
} on UnauthorizedException {
return Left(UnauthorizedFailure('Session expirée'));
} on ServerException catch (e) {
return Left(ServerFailure(e.message));
} on TimeoutException {
return Left(NetworkFailure('Délai d\'attente dépassé'));
} catch (e) {
return Left(UnexpectedFailure('Erreur inattendue: $e'));
}
}
/// Determine if an error is retryable
bool _isRetryableError(dynamic error) {
// Server errors are retryable
if (error is ServerException) return true;
// Timeout errors are retryable
if (error is TimeoutException) return true;
// Client errors are not retryable
if (error is UnauthorizedException) return false;
if (error is ForbiddenException) return false;
if (error is NotFoundException) return false;
if (error is ValidationException) return false;
// Unknown errors - default to not retryable
return false;
}
// === MÉTHODES NON IMPLÉMENTÉES (Stubs) ===
@override
Future<Either<Failure, TransactionApproval>> requestApproval({
required String transactionId,
required TransactionType transactionType,
required double amount,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<TransactionApproval>>> getApprovalsHistory({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
ApprovalStatus? status,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Budget>> updateBudget({
required String budgetId,
String? name,
String? description,
List<BudgetLine>? lines,
BudgetStatus? status,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, void>> deleteBudget(String budgetId) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<FinancialAuditLog>>> getAuditLogs({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
AuditOperation? operation,
AuditEntityType? entityType,
AuditSeverity? severity,
int? limit,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, List<FinancialAuditLog>>> getAnomalies({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, String>> exportAuditLogs({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
String format = 'csv',
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
@override
Future<Either<Failure, Map<String, dynamic>>> getWorkflowStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
}) async {
return Left(
NotImplementedFailure('Fonctionnalité en cours de développement'));
}
}

View File

@@ -0,0 +1,244 @@
/// Entité métier Budget
///
/// Représente un budget prévisionnel (mensuel/annuel) avec suivi réalisé
library budget;
import 'package:equatable/equatable.dart';
/// Période du budget
enum BudgetPeriod {
/// Budget mensuel
monthly,
/// Budget trimestriel
quarterly,
/// Budget semestriel
semiannual,
/// Budget annuel
annual,
}
/// Statut du budget
enum BudgetStatus {
/// Brouillon (en cours de création)
draft,
/// Actif
active,
/// Clos (période terminée)
closed,
/// Annulé
cancelled,
}
/// Catégorie budgétaire
enum BudgetCategory {
/// Cotisations/contributions
contributions,
/// Épargne
savings,
/// Solidarité
solidarity,
/// Événements
events,
/// Fonctionnement (frais généraux)
operational,
/// Investissements
investments,
/// Autres
other,
}
/// Entité Budget
class Budget extends Equatable {
final String id;
final String name;
final String? description;
final String organizationId;
final BudgetPeriod period;
final int year;
final int? month;
final BudgetStatus status;
final List<BudgetLine> lines;
final double totalPlanned;
final double totalRealized;
final String currency;
final String createdBy;
final DateTime createdAt;
final DateTime? approvedAt;
final String? approvedBy;
final DateTime startDate;
final DateTime endDate;
final Map<String, dynamic>? metadata;
const Budget({
required this.id,
required this.name,
this.description,
required this.organizationId,
required this.period,
required this.year,
this.month,
required this.status,
this.lines = const [],
required this.totalPlanned,
this.totalRealized = 0,
this.currency = 'XOF',
required this.createdBy,
required this.createdAt,
this.approvedAt,
this.approvedBy,
required this.startDate,
required this.endDate,
this.metadata,
});
/// Taux de réalisation (%)
double get realizationRate {
if (totalPlanned == 0) return 0;
return (totalRealized / totalPlanned) * 100;
}
/// Écart (réalisé - prévu)
double get variance => totalRealized - totalPlanned;
/// Taux d'écart (%)
double get varianceRate {
if (totalPlanned == 0) return 0;
return (variance / totalPlanned) * 100;
}
/// Vérifie si le budget est dépassé
bool get isOverBudget => totalRealized > totalPlanned;
/// Vérifie si le budget est actif
bool get isActive => status == BudgetStatus.active;
/// Vérifie si la période est en cours
bool get isCurrentPeriod {
final now = DateTime.now();
return now.isAfter(startDate) && now.isBefore(endDate);
}
/// Copie avec modifications
Budget copyWith({
String? id,
String? name,
String? description,
String? organizationId,
BudgetPeriod? period,
int? year,
int? month,
BudgetStatus? status,
List<BudgetLine>? lines,
double? totalPlanned,
double? totalRealized,
String? currency,
String? createdBy,
DateTime? createdAt,
DateTime? approvedAt,
String? approvedBy,
DateTime? startDate,
DateTime? endDate,
Map<String, dynamic>? metadata,
}) {
return Budget(
id: id ?? this.id,
name: name ?? this.name,
description: description ?? this.description,
organizationId: organizationId ?? this.organizationId,
period: period ?? this.period,
year: year ?? this.year,
month: month ?? this.month,
status: status ?? this.status,
lines: lines ?? this.lines,
totalPlanned: totalPlanned ?? this.totalPlanned,
totalRealized: totalRealized ?? this.totalRealized,
currency: currency ?? this.currency,
createdBy: createdBy ?? this.createdBy,
createdAt: createdAt ?? this.createdAt,
approvedAt: approvedAt ?? this.approvedAt,
approvedBy: approvedBy ?? this.approvedBy,
startDate: startDate ?? this.startDate,
endDate: endDate ?? this.endDate,
metadata: metadata ?? this.metadata,
);
}
@override
List<Object?> get props => [
id,
name,
description,
organizationId,
period,
year,
month,
status,
lines,
totalPlanned,
totalRealized,
currency,
createdBy,
createdAt,
approvedAt,
approvedBy,
startDate,
endDate,
metadata,
];
}
/// Ligne budgétaire
class BudgetLine extends Equatable {
final String id;
final BudgetCategory category;
final String name;
final String? description;
final double amountPlanned;
final double amountRealized;
final String? notes;
const BudgetLine({
required this.id,
required this.category,
required this.name,
this.description,
required this.amountPlanned,
this.amountRealized = 0,
this.notes,
});
/// Taux de réalisation (%)
double get realizationRate {
if (amountPlanned == 0) return 0;
return (amountRealized / amountPlanned) * 100;
}
/// Écart
double get variance => amountRealized - amountPlanned;
/// Vérifie si la ligne est dépassée
bool get isOverBudget => amountRealized > amountPlanned;
@override
List<Object?> get props => [
id,
category,
name,
description,
amountPlanned,
amountRealized,
notes,
];
}

View File

@@ -0,0 +1,162 @@
/// Entité métier Audit Log Financier
///
/// Représente une entrée d'audit trail pour traçabilité financière complète
library financial_audit_log;
import 'package:equatable/equatable.dart';
/// Type d'opération auditée
enum AuditOperation {
/// Création
create,
/// Lecture/consultation
read,
/// Mise à jour
update,
/// Suppression
delete,
/// Approbation
approve,
/// Rejet
reject,
/// Validation
validate,
/// Annulation
cancel,
/// Export
export,
}
/// Type d'entité auditée
enum AuditEntityType {
/// Contribution/cotisation
contribution,
/// Transaction épargne
savingsTransaction,
/// Approbation
approval,
/// Budget
budget,
/// Dépense solidarité
solidarity,
/// Paiement
payment,
/// Autre
other,
}
/// Niveau de sévérité
enum AuditSeverity {
/// Information
info,
/// Avertissement
warning,
/// Erreur
error,
/// Critique (anomalie détectée)
critical,
}
/// Entité Audit Log Financier
class FinancialAuditLog extends Equatable {
final String id;
final AuditOperation operation;
final AuditEntityType entityType;
final String entityId;
final String userId;
final String userName;
final String userRole;
final String? organizationId;
final AuditSeverity severity;
final String description;
final Map<String, dynamic>? dataBefore;
final Map<String, dynamic>? dataAfter;
final double? amountBefore;
final double? amountAfter;
final String? ipAddress;
final String? userAgent;
final DateTime timestamp;
final Map<String, dynamic>? metadata;
const FinancialAuditLog({
required this.id,
required this.operation,
required this.entityType,
required this.entityId,
required this.userId,
required this.userName,
required this.userRole,
this.organizationId,
this.severity = AuditSeverity.info,
required this.description,
this.dataBefore,
this.dataAfter,
this.amountBefore,
this.amountAfter,
this.ipAddress,
this.userAgent,
required this.timestamp,
this.metadata,
});
/// Vérifie si c'est une opération de modification de montant
bool get isAmountChanged =>
amountBefore != null &&
amountAfter != null &&
amountBefore != amountAfter;
/// Écart de montant
double? get amountDifference {
if (amountBefore == null || amountAfter == null) return null;
return amountAfter! - amountBefore!;
}
/// Vérifie si c'est une anomalie critique
bool get isCritical => severity == AuditSeverity.critical;
/// Vérifie si c'est une opération sensible
bool get isSensitiveOperation =>
operation == AuditOperation.delete ||
operation == AuditOperation.approve ||
operation == AuditOperation.reject ||
operation == AuditOperation.cancel;
@override
List<Object?> get props => [
id,
operation,
entityType,
entityId,
userId,
userName,
userRole,
organizationId,
severity,
description,
dataBefore,
dataAfter,
amountBefore,
amountAfter,
ipAddress,
userAgent,
timestamp,
metadata,
];
}

View File

@@ -0,0 +1,241 @@
/// Entité métier Approbation Transaction
///
/// Représente une approbation dans le workflow financier multi-niveaux
library transaction_approval;
import 'package:equatable/equatable.dart';
/// Statut de l'approbation
enum ApprovalStatus {
/// En attente d'approbation
pending,
/// Approuvée (niveau 1)
approved,
/// Validée (niveau 2 - validation finale)
validated,
/// Rejetée
rejected,
/// Expirée (timeout)
expired,
/// Annulée
cancelled,
}
/// Niveau d'approbation requis selon montant
enum ApprovalLevel {
/// Aucune approbation requise (< seuil)
none,
/// Niveau 1 : Un approbateur requis
level1,
/// Niveau 2 : Deux approbateurs requis
level2,
/// Niveau 3 : Trois approbateurs requis (montants très élevés)
level3,
}
/// Type de transaction financière
enum TransactionType {
/// Cotisation/contribution
contribution,
/// Dépôt épargne
deposit,
/// Retrait épargne
withdrawal,
/// Transfert
transfer,
/// Dépense solidarité
solidarity,
/// Dépense événement
event,
/// Autre dépense
other,
}
/// Entité Approbation de Transaction
class TransactionApproval extends Equatable {
final String id;
final String transactionId;
final TransactionType transactionType;
final double amount;
final String currency;
final String requesterId;
final String requesterName;
final String? organizationId;
final ApprovalLevel requiredLevel;
final ApprovalStatus status;
final List<ApproverAction> approvers;
final String? rejectionReason;
final DateTime createdAt;
final DateTime? expiresAt;
final DateTime? completedAt;
final Map<String, dynamic>? metadata;
const TransactionApproval({
required this.id,
required this.transactionId,
required this.transactionType,
required this.amount,
this.currency = 'XOF',
required this.requesterId,
required this.requesterName,
this.organizationId,
required this.requiredLevel,
required this.status,
this.approvers = const [],
this.rejectionReason,
required this.createdAt,
this.expiresAt,
this.completedAt,
this.metadata,
});
/// Vérifie si l'approbation est en attente
bool get isPending => status == ApprovalStatus.pending;
/// Vérifie si l'approbation est complétée
bool get isCompleted =>
status == ApprovalStatus.validated ||
status == ApprovalStatus.rejected ||
status == ApprovalStatus.cancelled;
/// Vérifie si l'approbation est expirée
bool get isExpired {
if (expiresAt == null) return false;
return DateTime.now().isAfter(expiresAt!);
}
/// Nombre d'approbations reçues
int get approvalCount =>
approvers.where((a) => a.decision == ApprovalDecision.approved).length;
/// Nombre d'approbations requises
int get requiredApprovals {
switch (requiredLevel) {
case ApprovalLevel.none:
return 0;
case ApprovalLevel.level1:
return 1;
case ApprovalLevel.level2:
return 2;
case ApprovalLevel.level3:
return 3;
}
}
/// Vérifie si toutes les approbations sont reçues
bool get hasAllApprovals => approvalCount >= requiredApprovals;
/// Copie avec modifications
TransactionApproval copyWith({
String? id,
String? transactionId,
TransactionType? transactionType,
double? amount,
String? currency,
String? requesterId,
String? requesterName,
String? organizationId,
ApprovalLevel? requiredLevel,
ApprovalStatus? status,
List<ApproverAction>? approvers,
String? rejectionReason,
DateTime? createdAt,
DateTime? expiresAt,
DateTime? completedAt,
Map<String, dynamic>? metadata,
}) {
return TransactionApproval(
id: id ?? this.id,
transactionId: transactionId ?? this.transactionId,
transactionType: transactionType ?? this.transactionType,
amount: amount ?? this.amount,
currency: currency ?? this.currency,
requesterId: requesterId ?? this.requesterId,
requesterName: requesterName ?? this.requesterName,
organizationId: organizationId ?? this.organizationId,
requiredLevel: requiredLevel ?? this.requiredLevel,
status: status ?? this.status,
approvers: approvers ?? this.approvers,
rejectionReason: rejectionReason ?? this.rejectionReason,
createdAt: createdAt ?? this.createdAt,
expiresAt: expiresAt ?? this.expiresAt,
completedAt: completedAt ?? this.completedAt,
metadata: metadata ?? this.metadata,
);
}
@override
List<Object?> get props => [
id,
transactionId,
transactionType,
amount,
currency,
requesterId,
requesterName,
organizationId,
requiredLevel,
status,
approvers,
rejectionReason,
createdAt,
expiresAt,
completedAt,
metadata,
];
}
/// Décision d'un approbateur
enum ApprovalDecision {
/// En attente
pending,
/// Approuvé
approved,
/// Rejeté
rejected,
}
/// Action d'un approbateur
class ApproverAction extends Equatable {
final String approverId;
final String approverName;
final String approverRole;
final ApprovalDecision decision;
final String? comment;
final DateTime? decidedAt;
const ApproverAction({
required this.approverId,
required this.approverName,
required this.approverRole,
required this.decision,
this.comment,
this.decidedAt,
});
@override
List<Object?> get props => [
approverId,
approverName,
approverRole,
decision,
comment,
decidedAt,
];
}

View File

@@ -0,0 +1,125 @@
/// Repository interface pour le workflow financier
library finance_workflow_repository;
import 'package:dartz/dartz.dart';
import '../../../../core/error/failures.dart';
import '../entities/transaction_approval.dart';
import '../entities/budget.dart';
import '../entities/financial_audit_log.dart';
/// Interface du repository de workflow financier
abstract class FinanceWorkflowRepository {
// === APPROBATIONS ===
/// Récupère les approbations en attente pour un utilisateur
Future<Either<Failure, List<TransactionApproval>>> getPendingApprovals({
String? organizationId,
});
/// Récupère une approbation par son ID
Future<Either<Failure, TransactionApproval>> getApprovalById(String approvalId);
/// Approuve une transaction
Future<Either<Failure, TransactionApproval>> approveTransaction({
required String approvalId,
String? comment,
});
/// Rejette une transaction
Future<Either<Failure, TransactionApproval>> rejectTransaction({
required String approvalId,
required String reason,
});
/// Demande une approbation pour une transaction
Future<Either<Failure, TransactionApproval>> requestApproval({
required String transactionId,
required TransactionType transactionType,
required double amount,
});
/// Récupère l'historique des approbations
Future<Either<Failure, List<TransactionApproval>>> getApprovalsHistory({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
ApprovalStatus? status,
});
// === BUDGETS ===
/// Récupère tous les budgets
Future<Either<Failure, List<Budget>>> getBudgets({
String? organizationId,
BudgetStatus? status,
int? year,
});
/// Récupère un budget par son ID
Future<Either<Failure, Budget>> getBudgetById(String budgetId);
/// Crée un nouveau budget
Future<Either<Failure, Budget>> createBudget({
required String name,
String? description,
required String organizationId,
required BudgetPeriod period,
required int year,
int? month,
required List<BudgetLine> lines,
});
/// Met à jour un budget
Future<Either<Failure, Budget>> updateBudget({
required String budgetId,
String? name,
String? description,
List<BudgetLine>? lines,
BudgetStatus? status,
});
/// Supprime un budget
Future<Either<Failure, void>> deleteBudget(String budgetId);
/// Récupère le suivi budgétaire (réalisé vs prévu)
Future<Either<Failure, Map<String, dynamic>>> getBudgetTracking({
required String budgetId,
});
// === AUDIT LOGS ===
/// Récupère les logs d'audit
Future<Either<Failure, List<FinancialAuditLog>>> getAuditLogs({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
AuditOperation? operation,
AuditEntityType? entityType,
AuditSeverity? severity,
int? limit,
});
/// Récupère les anomalies détectées
Future<Either<Failure, List<FinancialAuditLog>>> getAnomalies({
String? organizationId,
DateTime? startDate,
DateTime? endDate,
});
/// Exporte les logs d'audit (CSV/PDF)
Future<Either<Failure, String>> exportAuditLogs({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
String format = 'csv',
});
// === STATISTIQUES ===
/// Récupère les statistiques de workflow
Future<Either<Failure, Map<String, dynamic>>> getWorkflowStats({
required String organizationId,
DateTime? startDate,
DateTime? endDate,
});
}

View File

@@ -0,0 +1,29 @@
/// Use case: Approuver une transaction
library approve_transaction;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/transaction_approval.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class ApproveTransaction {
final FinanceWorkflowRepository repository;
ApproveTransaction(this.repository);
Future<Either<Failure, TransactionApproval>> call({
required String approvalId,
String? comment,
}) async {
if (approvalId.isEmpty) {
return Left(ValidationFailure('ID approbation requis'));
}
return await repository.approveTransaction(
approvalId: approvalId,
comment: comment,
);
}
}

View File

@@ -0,0 +1,56 @@
/// Use case: Créer un budget
library create_budget;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/budget.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class CreateBudget {
final FinanceWorkflowRepository repository;
CreateBudget(this.repository);
Future<Either<Failure, Budget>> call({
required String name,
String? description,
required String organizationId,
required BudgetPeriod period,
required int year,
int? month,
required List<BudgetLine> lines,
}) async {
// Validation
if (name.trim().isEmpty) {
return Left(ValidationFailure('Nom du budget requis'));
}
if (organizationId.isEmpty) {
return Left(ValidationFailure('ID organisation requis'));
}
if (year < 2000 || year > 2100) {
return Left(ValidationFailure('Année invalide'));
}
if (period == BudgetPeriod.monthly && (month == null || month < 1 || month > 12)) {
return Left(ValidationFailure('Mois requis pour budget mensuel (1-12)'));
}
if (lines.isEmpty) {
return Left(ValidationFailure('Au moins une ligne budgétaire requise'));
}
return await repository.createBudget(
name: name,
description: description,
organizationId: organizationId,
period: period,
year: year,
month: month,
lines: lines,
);
}
}

View File

@@ -0,0 +1,23 @@
/// Use case: Récupérer une approbation par ID
library get_approval_by_id;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/transaction_approval.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class GetApprovalById {
final FinanceWorkflowRepository repository;
GetApprovalById(this.repository);
Future<Either<Failure, TransactionApproval>> call(String approvalId) async {
if (approvalId.isEmpty) {
return Left(ValidationFailure('ID approbation requis'));
}
return await repository.getApprovalById(approvalId);
}
}

View File

@@ -0,0 +1,23 @@
/// Use case: Récupérer un budget par ID
library get_budget_by_id;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/budget.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class GetBudgetById {
final FinanceWorkflowRepository repository;
GetBudgetById(this.repository);
Future<Either<Failure, Budget>> call(String budgetId) async {
if (budgetId.isEmpty) {
return Left(ValidationFailure('ID budget requis'));
}
return await repository.getBudgetById(budgetId);
}
}

View File

@@ -0,0 +1,24 @@
/// Use case: Récupérer le suivi budgétaire
library get_budget_tracking;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class GetBudgetTracking {
final FinanceWorkflowRepository repository;
GetBudgetTracking(this.repository);
Future<Either<Failure, Map<String, dynamic>>> call({
required String budgetId,
}) async {
if (budgetId.isEmpty) {
return Left(ValidationFailure('ID budget requis'));
}
return await repository.getBudgetTracking(budgetId: budgetId);
}
}

View File

@@ -0,0 +1,27 @@
/// Use case: Récupérer les budgets
library get_budgets;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/budget.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class GetBudgets {
final FinanceWorkflowRepository repository;
GetBudgets(this.repository);
Future<Either<Failure, List<Budget>>> call({
String? organizationId,
BudgetStatus? status,
int? year,
}) async {
return await repository.getBudgets(
organizationId: organizationId,
status: status,
year: year,
);
}
}

View File

@@ -0,0 +1,23 @@
/// Use case: Récupérer les approbations en attente
library get_pending_approvals;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/transaction_approval.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class GetPendingApprovals {
final FinanceWorkflowRepository repository;
GetPendingApprovals(this.repository);
Future<Either<Failure, List<TransactionApproval>>> call({
String? organizationId,
}) async {
return await repository.getPendingApprovals(
organizationId: organizationId,
);
}
}

View File

@@ -0,0 +1,33 @@
/// Use case: Rejeter une transaction
library reject_transaction;
import 'package:dartz/dartz.dart';
import 'package:injectable/injectable.dart';
import '../../../../core/error/failures.dart';
import '../entities/transaction_approval.dart';
import '../repositories/finance_workflow_repository.dart';
@lazySingleton
class RejectTransaction {
final FinanceWorkflowRepository repository;
RejectTransaction(this.repository);
Future<Either<Failure, TransactionApproval>> call({
required String approvalId,
required String reason,
}) async {
if (approvalId.isEmpty) {
return Left(ValidationFailure('ID approbation requis'));
}
if (reason.trim().isEmpty) {
return Left(ValidationFailure('Raison du rejet requise'));
}
return await repository.rejectTransaction(
approvalId: approvalId,
reason: reason,
);
}
}

View File

@@ -0,0 +1,129 @@
/// BLoC pour la gestion des approbations de transactions
library approval_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_pending_approvals.dart';
import '../../domain/usecases/approve_transaction.dart';
import '../../domain/usecases/reject_transaction.dart';
import '../../domain/usecases/get_approval_by_id.dart';
import 'approval_event.dart';
import 'approval_state.dart';
@injectable
class ApprovalBloc extends Bloc<ApprovalEvent, ApprovalState> {
final GetPendingApprovals getPendingApprovals;
final GetApprovalById getApprovalById;
final ApproveTransaction approveTransaction;
final RejectTransaction rejectTransaction;
ApprovalBloc({
required this.getPendingApprovals,
required this.getApprovalById,
required this.approveTransaction,
required this.rejectTransaction,
}) : super(const ApprovalInitial()) {
on<LoadPendingApprovals>(_onLoadPendingApprovals);
on<LoadApprovalById>(_onLoadApprovalById);
on<ApproveTransactionEvent>(_onApproveTransaction);
on<RejectTransactionEvent>(_onRejectTransaction);
on<RefreshApprovals>(_onRefreshApprovals);
}
Future<void> _onLoadPendingApprovals(
LoadPendingApprovals event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalsLoading());
final result = await getPendingApprovals(
organizationId: event.organizationId,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approvals) {
if (approvals.isEmpty) {
emit(const ApprovalsEmpty());
} else {
emit(ApprovalsLoaded(
approvals: approvals,
pendingCount: approvals.length,
));
}
},
);
}
Future<void> _onLoadApprovalById(
LoadApprovalById event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalsLoading());
final result = await getApprovalById(event.approvalId);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(ApprovalDetailLoaded(approval)),
);
}
Future<void> _onApproveTransaction(
ApproveTransactionEvent event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalActionInProgress('approve'));
final result = await approveTransaction(
approvalId: event.approvalId,
comment: event.comment,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(TransactionApproved(approval: approval)),
);
}
Future<void> _onRejectTransaction(
RejectTransactionEvent event,
Emitter<ApprovalState> emit,
) async {
emit(const ApprovalActionInProgress('reject'));
final result = await rejectTransaction(
approvalId: event.approvalId,
reason: event.reason,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approval) => emit(TransactionRejected(approval: approval)),
);
}
Future<void> _onRefreshApprovals(
RefreshApprovals event,
Emitter<ApprovalState> emit,
) async {
// Keep current state while refreshing
final result = await getPendingApprovals(
organizationId: event.organizationId,
);
result.fold(
(failure) => emit(ApprovalError(failure.message)),
(approvals) {
if (approvals.isEmpty) {
emit(const ApprovalsEmpty());
} else {
emit(ApprovalsLoaded(
approvals: approvals,
pendingCount: approvals.length,
));
}
},
);
}
}

View File

@@ -0,0 +1,69 @@
/// Événements pour le BLoC des approbations
library approval_event;
import 'package:equatable/equatable.dart';
abstract class ApprovalEvent extends Equatable {
const ApprovalEvent();
@override
List<Object?> get props => [];
}
/// Charger les approbations en attente
class LoadPendingApprovals extends ApprovalEvent {
final String? organizationId;
const LoadPendingApprovals({this.organizationId});
@override
List<Object?> get props => [organizationId];
}
/// Charger une approbation spécifique
class LoadApprovalById extends ApprovalEvent {
final String approvalId;
const LoadApprovalById(this.approvalId);
@override
List<Object?> get props => [approvalId];
}
/// Approuver une transaction
class ApproveTransactionEvent extends ApprovalEvent {
final String approvalId;
final String? comment;
const ApproveTransactionEvent({
required this.approvalId,
this.comment,
});
@override
List<Object?> get props => [approvalId, comment];
}
/// Rejeter une transaction
class RejectTransactionEvent extends ApprovalEvent {
final String approvalId;
final String reason;
const RejectTransactionEvent({
required this.approvalId,
required this.reason,
});
@override
List<Object?> get props => [approvalId, reason];
}
/// Rafraîchir les approbations
class RefreshApprovals extends ApprovalEvent {
final String? organizationId;
const RefreshApprovals({this.organizationId});
@override
List<Object?> get props => [organizationId];
}

View File

@@ -0,0 +1,106 @@
/// États pour le BLoC des approbations
library approval_state;
import 'package:equatable/equatable.dart';
import '../../domain/entities/transaction_approval.dart';
abstract class ApprovalState extends Equatable {
const ApprovalState();
@override
List<Object?> get props => [];
}
/// État initial
class ApprovalInitial extends ApprovalState {
const ApprovalInitial();
}
/// Chargement en cours
class ApprovalsLoading extends ApprovalState {
const ApprovalsLoading();
}
/// Approbations chargées
class ApprovalsLoaded extends ApprovalState {
final List<TransactionApproval> approvals;
final int pendingCount;
const ApprovalsLoaded({
required this.approvals,
required this.pendingCount,
});
@override
List<Object?> get props => [approvals, pendingCount];
}
/// Approbation spécifique chargée
class ApprovalDetailLoaded extends ApprovalState {
final TransactionApproval approval;
const ApprovalDetailLoaded(this.approval);
@override
List<Object?> get props => [approval];
}
/// Transaction approuvée avec succès
class TransactionApproved extends ApprovalState {
final TransactionApproval approval;
final String message;
const TransactionApproved({
required this.approval,
this.message = 'Transaction approuvée avec succès',
});
@override
List<Object?> get props => [approval, message];
}
/// Transaction rejetée avec succès
class TransactionRejected extends ApprovalState {
final TransactionApproval approval;
final String message;
const TransactionRejected({
required this.approval,
this.message = 'Transaction rejetée avec succès',
});
@override
List<Object?> get props => [approval, message];
}
/// Action en cours (approve/reject)
class ApprovalActionInProgress extends ApprovalState {
final String actionType; // 'approve' or 'reject'
const ApprovalActionInProgress(this.actionType);
@override
List<Object?> get props => [actionType];
}
/// Erreur
class ApprovalError extends ApprovalState {
final String message;
const ApprovalError(this.message);
@override
List<Object?> get props => [message];
}
/// Liste vide
class ApprovalsEmpty extends ApprovalState {
final String message;
const ApprovalsEmpty({
this.message = 'Aucune approbation en attente',
});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,187 @@
/// BLoC pour la gestion des budgets
library budget_bloc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import '../../domain/usecases/get_budgets.dart';
import '../../domain/usecases/get_budget_by_id.dart';
import '../../domain/usecases/create_budget.dart';
import '../../domain/usecases/get_budget_tracking.dart';
import 'budget_event.dart';
import 'budget_state.dart';
@injectable
class BudgetBloc extends Bloc<BudgetEvent, BudgetState> {
final GetBudgets getBudgets;
final GetBudgetById getBudgetById;
final CreateBudget createBudget;
final GetBudgetTracking getBudgetTracking;
BudgetBloc({
required this.getBudgets,
required this.getBudgetById,
required this.createBudget,
required this.getBudgetTracking,
}) : super(const BudgetInitial()) {
on<LoadBudgets>(_onLoadBudgets);
on<LoadBudgetById>(_onLoadBudgetById);
on<CreateBudgetEvent>(_onCreateBudget);
on<LoadBudgetTracking>(_onLoadBudgetTracking);
on<RefreshBudgets>(_onRefreshBudgets);
on<FilterBudgets>(_onFilterBudgets);
}
Future<void> _onLoadBudgets(
LoadBudgets event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetsLoading());
final result = await getBudgets(
organizationId: event.organizationId,
status: event.status,
year: event.year,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budgets) {
if (budgets.isEmpty) {
emit(const BudgetsEmpty());
} else {
emit(BudgetsLoaded(
budgets: budgets,
filterStatus: event.status,
filterYear: event.year,
));
}
},
);
}
Future<void> _onLoadBudgetById(
LoadBudgetById event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetsLoading());
final result = await getBudgetById(event.budgetId);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budget) => emit(BudgetDetailLoaded(budget)),
);
}
Future<void> _onCreateBudget(
CreateBudgetEvent event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetActionInProgress('create'));
final result = await createBudget(
name: event.name,
description: event.description,
organizationId: event.organizationId,
period: event.period,
year: event.year,
month: event.month,
lines: event.lines,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budget) => emit(BudgetCreated(budget: budget)),
);
}
Future<void> _onLoadBudgetTracking(
LoadBudgetTracking event,
Emitter<BudgetState> emit,
) async {
emit(const BudgetsLoading());
// Load budget first
final budgetResult = await getBudgetById(event.budgetId);
await budgetResult.fold(
(failure) async => emit(BudgetError(failure.message)),
(budget) async {
// Then load tracking
final trackingResult = await getBudgetTracking(budgetId: event.budgetId);
trackingResult.fold(
(failure) => emit(BudgetError(failure.message)),
(tracking) => emit(BudgetTrackingLoaded(
budget: budget,
tracking: tracking,
)),
);
},
);
}
Future<void> _onRefreshBudgets(
RefreshBudgets event,
Emitter<BudgetState> emit,
) async {
final result = await getBudgets(
organizationId: event.organizationId,
status: event.status,
year: event.year,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budgets) {
if (budgets.isEmpty) {
emit(const BudgetsEmpty());
} else {
emit(BudgetsLoaded(
budgets: budgets,
filterStatus: event.status,
filterYear: event.year,
));
}
},
);
}
Future<void> _onFilterBudgets(
FilterBudgets event,
Emitter<BudgetState> emit,
) async {
// Keep current organization if in loaded state
String? organizationId;
if (state is BudgetsLoaded) {
final currentState = state as BudgetsLoaded;
// Extract org ID from first budget if available
if (currentState.budgets.isNotEmpty) {
organizationId = currentState.budgets.first.organizationId;
}
}
emit(const BudgetsLoading());
final result = await getBudgets(
organizationId: organizationId,
status: event.status,
year: event.year,
);
result.fold(
(failure) => emit(BudgetError(failure.message)),
(budgets) {
if (budgets.isEmpty) {
emit(const BudgetsEmpty());
} else {
emit(BudgetsLoaded(
budgets: budgets,
filterStatus: event.status,
filterYear: event.year,
));
}
},
);
}
}

View File

@@ -0,0 +1,110 @@
/// Événements pour le BLoC des budgets
library budget_event;
import 'package:equatable/equatable.dart';
import '../../domain/entities/budget.dart';
abstract class BudgetEvent extends Equatable {
const BudgetEvent();
@override
List<Object?> get props => [];
}
/// Charger les budgets
class LoadBudgets extends BudgetEvent {
final String? organizationId;
final BudgetStatus? status;
final int? year;
const LoadBudgets({
this.organizationId,
this.status,
this.year,
});
@override
List<Object?> get props => [organizationId, status, year];
}
/// Charger un budget spécifique
class LoadBudgetById extends BudgetEvent {
final String budgetId;
const LoadBudgetById(this.budgetId);
@override
List<Object?> get props => [budgetId];
}
/// Créer un budget
class CreateBudgetEvent extends BudgetEvent {
final String name;
final String? description;
final String organizationId;
final BudgetPeriod period;
final int year;
final int? month;
final List<BudgetLine> lines;
const CreateBudgetEvent({
required this.name,
this.description,
required this.organizationId,
required this.period,
required this.year,
this.month,
required this.lines,
});
@override
List<Object?> get props => [
name,
description,
organizationId,
period,
year,
month,
lines,
];
}
/// Charger le suivi budgétaire
class LoadBudgetTracking extends BudgetEvent {
final String budgetId;
const LoadBudgetTracking(this.budgetId);
@override
List<Object?> get props => [budgetId];
}
/// Rafraîchir les budgets
class RefreshBudgets extends BudgetEvent {
final String? organizationId;
final BudgetStatus? status;
final int? year;
const RefreshBudgets({
this.organizationId,
this.status,
this.year,
});
@override
List<Object?> get props => [organizationId, status, year];
}
/// Filtrer les budgets
class FilterBudgets extends BudgetEvent {
final BudgetStatus? status;
final int? year;
const FilterBudgets({
this.status,
this.year,
});
@override
List<Object?> get props => [status, year];
}

View File

@@ -0,0 +1,108 @@
/// États pour le BLoC des budgets
library budget_state;
import 'package:equatable/equatable.dart';
import '../../domain/entities/budget.dart';
abstract class BudgetState extends Equatable {
const BudgetState();
@override
List<Object?> get props => [];
}
/// État initial
class BudgetInitial extends BudgetState {
const BudgetInitial();
}
/// Chargement en cours
class BudgetsLoading extends BudgetState {
const BudgetsLoading();
}
/// Budgets chargés
class BudgetsLoaded extends BudgetState {
final List<Budget> budgets;
final BudgetStatus? filterStatus;
final int? filterYear;
const BudgetsLoaded({
required this.budgets,
this.filterStatus,
this.filterYear,
});
@override
List<Object?> get props => [budgets, filterStatus, filterYear];
}
/// Budget spécifique chargé
class BudgetDetailLoaded extends BudgetState {
final Budget budget;
const BudgetDetailLoaded(this.budget);
@override
List<Object?> get props => [budget];
}
/// Suivi budgétaire chargé
class BudgetTrackingLoaded extends BudgetState {
final Budget budget;
final Map<String, dynamic> tracking;
const BudgetTrackingLoaded({
required this.budget,
required this.tracking,
});
@override
List<Object?> get props => [budget, tracking];
}
/// Budget créé avec succès
class BudgetCreated extends BudgetState {
final Budget budget;
final String message;
const BudgetCreated({
required this.budget,
this.message = 'Budget créé avec succès',
});
@override
List<Object?> get props => [budget, message];
}
/// Action en cours (create, update)
class BudgetActionInProgress extends BudgetState {
final String actionType;
const BudgetActionInProgress(this.actionType);
@override
List<Object?> get props => [actionType];
}
/// Erreur
class BudgetError extends BudgetState {
final String message;
const BudgetError(this.message);
@override
List<Object?> get props => [message];
}
/// Liste vide
class BudgetsEmpty extends BudgetState {
final String message;
const BudgetsEmpty({
this.message = 'Aucun budget trouvé',
});
@override
List<Object?> get props => [message];
}

View File

@@ -0,0 +1,534 @@
/// Page de liste des budgets
library budgets_list_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/budget.dart';
import '../bloc/budget_bloc.dart';
import '../bloc/budget_event.dart';
import '../bloc/budget_state.dart';
class BudgetsListPage extends StatelessWidget {
final String? organizationId;
const BudgetsListPage({
super.key,
this.organizationId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<BudgetBloc>()
..add(LoadBudgets(organizationId: organizationId)),
child: _BudgetsListView(organizationId: organizationId),
);
}
}
class _BudgetsListView extends StatelessWidget {
final String? organizationId;
const _BudgetsListView({this.organizationId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'BUDGETS',
automaticallyImplyLeading: true,
actions: [
IconButton(
icon: const Icon(Icons.filter_list),
onPressed: () => _showFilterDialog(context),
),
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
context.read<BudgetBloc>().add(
RefreshBudgets(organizationId: organizationId),
);
},
),
],
),
body: BlocConsumer<BudgetBloc, BudgetState>(
listener: (context, state) {
if (state is BudgetCreated) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
context.read<BudgetBloc>().add(
RefreshBudgets(organizationId: organizationId),
);
} else if (state is BudgetError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
if (state is BudgetsLoading || state is BudgetActionInProgress) {
return const Center(child: CircularProgressIndicator());
}
if (state is BudgetsEmpty) {
return _buildEmptyState(state.message);
}
if (state is BudgetsLoaded) {
return _buildBudgetsList(context, state);
}
if (state is BudgetError) {
return _buildErrorState(context, state.message);
}
return const SizedBox.shrink();
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
// TODO: Navigate to create budget page
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Fonctionnalité en cours de développement'),
),
);
},
icon: const Icon(Icons.add),
label: const Text('Nouveau budget'),
backgroundColor: AppColors.primaryGreen,
),
);
}
Widget _buildBudgetsList(BuildContext context, BudgetsLoaded state) {
return RefreshIndicator(
onRefresh: () async {
context.read<BudgetBloc>().add(
RefreshBudgets(
organizationId: organizationId,
status: state.filterStatus,
year: state.filterYear,
),
);
},
child: Column(
children: [
if (state.filterStatus != null || state.filterYear != null)
_buildFilterChips(context, state),
Expanded(
child: ListView.separated(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: state.budgets.length,
separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.md),
itemBuilder: (context, index) {
final budget = state.budgets[index];
return _BudgetCard(budget: budget);
},
),
),
],
),
);
}
Widget _buildFilterChips(BuildContext context, BudgetsLoaded state) {
return Container(
padding: const EdgeInsets.all(SpacingTokens.md),
color: AppColors.lightBackground,
child: Wrap(
spacing: SpacingTokens.sm,
runSpacing: SpacingTokens.sm,
children: [
if (state.filterStatus != null)
Chip(
label: Text(_getStatusLabel(state.filterStatus!)),
onDeleted: () {
context.read<BudgetBloc>().add(
FilterBudgets(
status: null,
year: state.filterYear,
),
);
},
),
if (state.filterYear != null)
Chip(
label: Text('Année ${state.filterYear}'),
onDeleted: () {
context.read<BudgetBloc>().add(
FilterBudgets(
status: state.filterStatus,
year: null,
),
);
},
),
],
),
);
}
Widget _buildEmptyState(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.account_balance_wallet_outlined,
size: 80,
color: AppColors.textSecondaryLight.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
message,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
Widget _buildErrorState(BuildContext context, String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80,
color: AppColors.error.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
message,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<BudgetBloc>().add(
LoadBudgets(organizationId: organizationId),
);
},
icon: Icons.refresh,
),
],
),
);
}
void _showFilterDialog(BuildContext context) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<BudgetBloc>(),
child: _FilterDialog(organizationId: organizationId),
),
);
}
String _getStatusLabel(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return 'Brouillon';
case BudgetStatus.active:
return 'Actif';
case BudgetStatus.closed:
return 'Clos';
case BudgetStatus.cancelled:
return 'Annulé';
}
}
}
class _BudgetCard extends StatelessWidget {
final Budget budget;
const _BudgetCard({required this.budget});
String _getPeriodLabel(BudgetPeriod period) {
switch (period) {
case BudgetPeriod.monthly:
return 'Mensuel';
case BudgetPeriod.quarterly:
return 'Trimestriel';
case BudgetPeriod.semiannual:
return 'Semestriel';
case BudgetPeriod.annual:
return 'Annuel';
}
}
Color _getStatusColor(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return AppColors.textSecondaryLight;
case BudgetStatus.active:
return AppColors.brandGreen;
case BudgetStatus.closed:
return AppColors.textSecondaryLight;
case BudgetStatus.cancelled:
return AppColors.error;
}
}
String _getStatusLabel(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return 'Brouillon';
case BudgetStatus.active:
return 'Actif';
case BudgetStatus.closed:
return 'Clos';
case BudgetStatus.cancelled:
return 'Annulé';
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: budget.currency);
return Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(color: AppColors.lightBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
budget.name,
style: AppTypography.actionText,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getStatusColor(budget.status).withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: Text(
_getStatusLabel(budget.status),
style: AppTypography.badgeText.copyWith(
color: _getStatusColor(budget.status),
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
Text(
'${_getPeriodLabel(budget.period)} ${budget.year}',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: SpacingTokens.md),
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Prévu',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: 4),
Text(
currencyFormat.format(budget.totalPlanned),
style: AppTypography.headerSmall.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Réalisé',
style: AppTypography.subtitleSmall,
),
const SizedBox(height: 4),
Text(
currencyFormat.format(budget.totalRealized),
style: AppTypography.headerSmall.copyWith(
color: budget.isOverBudget
? AppColors.error
: AppColors.brandGreen,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
LinearProgressIndicator(
value: budget.realizationRate / 100,
backgroundColor: AppColors.lightBorder,
color: budget.isOverBudget
? AppColors.error
: AppColors.brandGreen,
),
const SizedBox(height: SpacingTokens.xs),
Text(
'${budget.realizationRate.toStringAsFixed(1)}% réalisé',
style: AppTypography.subtitleSmall,
),
],
),
);
}
}
class _FilterDialog extends StatefulWidget {
final String? organizationId;
const _FilterDialog({this.organizationId});
@override
State<_FilterDialog> createState() => _FilterDialogState();
}
class _FilterDialogState extends State<_FilterDialog> {
BudgetStatus? _selectedStatus;
int? _selectedYear;
final _currentYear = DateTime.now().year;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Filtrer les budgets'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Statut',
style: AppTypography.actionText,
),
const SizedBox(height: SpacingTokens.sm),
Wrap(
spacing: SpacingTokens.sm,
children: [
for (final status in BudgetStatus.values)
ChoiceChip(
label: Text(_getStatusLabel(status)),
selected: _selectedStatus == status,
onSelected: (selected) {
setState(() {
_selectedStatus = selected ? status : null;
});
},
),
],
),
const SizedBox(height: SpacingTokens.md),
Text(
'Année',
style: AppTypography.actionText,
),
const SizedBox(height: SpacingTokens.sm),
Wrap(
spacing: SpacingTokens.sm,
children: [
for (int year = _currentYear; year >= _currentYear - 5; year--)
ChoiceChip(
label: Text(year.toString()),
selected: _selectedYear == year,
onSelected: (selected) {
setState(() {
_selectedYear = selected ? year : null;
});
},
),
],
),
],
),
),
actions: [
TextButton(
onPressed: () {
setState(() {
_selectedStatus = null;
_selectedYear = null;
});
},
child: const Text('Réinitialiser'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton(
onPressed: () {
context.read<BudgetBloc>().add(
FilterBudgets(
status: _selectedStatus,
year: _selectedYear,
),
);
Navigator.of(context).pop();
},
child: const Text('Appliquer'),
),
],
);
}
String _getStatusLabel(BudgetStatus status) {
switch (status) {
case BudgetStatus.draft:
return 'Brouillon';
case BudgetStatus.active:
return 'Actif';
case BudgetStatus.closed:
return 'Clos';
case BudgetStatus.cancelled:
return 'Annulé';
}
}
}

View File

@@ -0,0 +1,388 @@
/// Page des approbations en attente
library pending_approvals_page;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/di/injection_container.dart';
import 'package:intl/intl.dart';
import '../../../../core/di/injection_container.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/transaction_approval.dart';
import '../bloc/approval_bloc.dart';
import '../bloc/approval_event.dart';
import '../bloc/approval_state.dart';
import '../widgets/approve_dialog.dart';
import '../widgets/reject_dialog.dart';
class PendingApprovalsPage extends StatelessWidget {
final String? organizationId;
const PendingApprovalsPage({
super.key,
this.organizationId,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<ApprovalBloc>()
..add(LoadPendingApprovals(organizationId: organizationId)),
child: _PendingApprovalsView(organizationId: organizationId),
);
}
}
class _PendingApprovalsView extends StatelessWidget {
final String? organizationId;
const _PendingApprovalsView({this.organizationId});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: ColorTokens.background,
appBar: UFAppBar(
title: 'APPROBATIONS EN ATTENTE',
automaticallyImplyLeading: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () {
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
},
),
],
),
body: BlocConsumer<ApprovalBloc, ApprovalState>(
listener: (context, state) {
if (state is TransactionApproved) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.success,
),
);
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
} else if (state is TransactionRejected) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.warning,
),
);
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
} else if (state is ApprovalError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: AppColors.error,
),
);
}
},
builder: (context, state) {
if (state is ApprovalsLoading || state is ApprovalActionInProgress) {
return const Center(child: CircularProgressIndicator());
}
if (state is ApprovalsEmpty) {
return _buildEmptyState();
}
if (state is ApprovalsLoaded) {
return _buildApprovalsList(context, state.approvals);
}
if (state is ApprovalError) {
return _buildErrorState(context, state.message);
}
return const SizedBox.shrink();
},
),
);
}
Widget _buildApprovalsList(
BuildContext context,
List<TransactionApproval> approvals,
) {
return RefreshIndicator(
onRefresh: () async {
context.read<ApprovalBloc>().add(
RefreshApprovals(organizationId: organizationId),
);
},
child: ListView.separated(
padding: const EdgeInsets.all(SpacingTokens.md),
itemCount: approvals.length,
separatorBuilder: (_, __) => const SizedBox(height: SpacingTokens.md),
itemBuilder: (context, index) {
final approval = approvals[index];
return _ApprovalCard(
approval: approval,
onApprove: () => _showApproveDialog(context, approval),
onReject: () => _showRejectDialog(context, approval),
);
},
),
);
}
void _showApproveDialog(BuildContext context, TransactionApproval approval) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<ApprovalBloc>(),
child: ApproveDialog(approval: approval),
),
);
}
void _showRejectDialog(BuildContext context, TransactionApproval approval) {
showDialog(
context: context,
builder: (dialogContext) => BlocProvider.value(
value: context.read<ApprovalBloc>(),
child: RejectDialog(approval: approval),
),
);
}
Widget _buildErrorState(BuildContext context, String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 80,
color: AppColors.error.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
message,
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
textAlign: TextAlign.center,
),
const SizedBox(height: SpacingTokens.lg),
UFPrimaryButton(
label: 'Réessayer',
onPressed: () {
context.read<ApprovalBloc>().add(
LoadPendingApprovals(organizationId: organizationId),
);
},
icon: Icons.refresh,
),
],
),
);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.check_circle_outline,
size: 80,
color: AppColors.success.withOpacity(0.5),
),
const SizedBox(height: SpacingTokens.lg),
Text(
'Aucune approbation en attente',
style: AppTypography.headerSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
const SizedBox(height: SpacingTokens.sm),
Text(
'Toutes les transactions sont approuvées',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.textSecondaryLight,
),
),
],
),
);
}
}
class _ApprovalCard extends StatelessWidget {
final TransactionApproval approval;
final VoidCallback onApprove;
final VoidCallback onReject;
const _ApprovalCard({
required this.approval,
required this.onApprove,
required this.onReject,
});
String _getTransactionTypeLabel(TransactionType type) {
switch (type) {
case TransactionType.contribution:
return 'Cotisation';
case TransactionType.deposit:
return 'Dépôt';
case TransactionType.withdrawal:
return 'Retrait';
case TransactionType.transfer:
return 'Transfert';
case TransactionType.solidarity:
return 'Solidarité';
case TransactionType.event:
return 'Événement';
case TransactionType.other:
return 'Autre';
}
}
Color _getLevelColor(ApprovalLevel level) {
switch (level) {
case ApprovalLevel.none:
return AppColors.textSecondaryLight;
case ApprovalLevel.level1:
return AppColors.brandGreen;
case ApprovalLevel.level2:
return AppColors.warning;
case ApprovalLevel.level3:
return AppColors.error;
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: approval.currency);
return Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(SpacingTokens.radiusMd),
border: Border.all(color: AppColors.lightBorder),
boxShadow: const [
BoxShadow(
color: Color(0x0A000000),
blurRadius: 8,
offset: Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_getTransactionTypeLabel(approval.transactionType),
style: AppTypography.actionText,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: _getLevelColor(approval.requiredLevel).withOpacity(0.1),
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
),
child: Text(
'Niveau ${approval.requiredApprovals}',
style: AppTypography.badgeText.copyWith(
color: _getLevelColor(approval.requiredLevel),
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
Text(
currencyFormat.format(approval.amount),
style: AppTypography.headerSmall.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.sm),
Row(
children: [
Icon(
Icons.person_outline,
size: 16,
color: AppColors.textSecondaryLight,
),
const SizedBox(width: 4),
Expanded(
child: Text(
'Demandé par ${approval.requesterName}',
style: AppTypography.subtitleSmall,
),
),
],
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.access_time,
size: 16,
color: AppColors.textSecondaryLight,
),
const SizedBox(width: 4),
Text(
DateFormat('dd/MM/yyyy HH:mm').format(approval.createdAt),
style: AppTypography.subtitleSmall,
),
],
),
if (approval.approvers.isNotEmpty) ...[
const SizedBox(height: SpacingTokens.sm),
const Divider(),
const SizedBox(height: SpacingTokens.sm),
Text(
'Approbations : ${approval.approvalCount}/${approval.requiredApprovals}',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
],
const SizedBox(height: SpacingTokens.md),
Row(
children: [
Expanded(
child: UFSecondaryButton(
label: 'Rejeter',
onPressed: onReject,
icon: Icons.close,
),
),
const SizedBox(width: SpacingTokens.sm),
Expanded(
child: UFPrimaryButton(
label: 'Approuver',
onPressed: onApprove,
icon: Icons.check,
),
),
],
),
],
),
);
}
}

View File

@@ -0,0 +1,177 @@
/// Dialog pour approuver une transaction
library approve_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/validation/validators.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/transaction_approval.dart';
import '../bloc/approval_bloc.dart';
import '../bloc/approval_event.dart';
class ApproveDialog extends StatefulWidget {
final TransactionApproval approval;
const ApproveDialog({
super.key,
required this.approval,
});
@override
State<ApproveDialog> createState() => _ApproveDialogState();
}
class _ApproveDialogState extends State<ApproveDialog> {
final _commentController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_commentController.dispose();
super.dispose();
}
String _getTransactionTypeLabel(TransactionType type) {
switch (type) {
case TransactionType.contribution:
return 'Cotisation';
case TransactionType.deposit:
return 'Dépôt';
case TransactionType.withdrawal:
return 'Retrait';
case TransactionType.transfer:
return 'Transfert';
case TransactionType.solidarity:
return 'Solidarité';
case TransactionType.event:
return 'Événement';
case TransactionType.other:
return 'Autre';
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: widget.approval.currency);
return AlertDialog(
title: const Text('Approuver la transaction'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Confirmez-vous l\'approbation de cette transaction ?',
style: AppTypography.bodyTextSmall,
),
const SizedBox(height: SpacingTokens.md),
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: AppColors.lightBackground,
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
border: Border.all(color: AppColors.lightBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
'Type',
_getTransactionTypeLabel(widget.approval.transactionType),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Montant',
currencyFormat.format(widget.approval.amount),
valueStyle: AppTypography.actionText.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Demandeur',
widget.approval.requesterName,
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Date',
DateFormat('dd/MM/yyyy HH:mm').format(widget.approval.createdAt),
),
],
),
),
const SizedBox(height: SpacingTokens.md),
TextFormField(
controller: _commentController,
decoration: const InputDecoration(
labelText: 'Commentaire (optionnel)',
hintText: 'Ajouter un commentaire...',
border: OutlineInputBorder(),
helperText: 'Maximum 500 caractères',
),
maxLines: 3,
maxLength: 500,
validator: FinanceValidators.approvalComment(),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () {
if (_formKey.currentState!.validate()) {
context.read<ApprovalBloc>().add(
ApproveTransactionEvent(
approvalId: widget.approval.id,
comment: _commentController.text.trim().isEmpty
? null
: _commentController.text.trim(),
),
);
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.check),
label: const Text('Approuver'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildInfoRow(String label, String value, {TextStyle? valueStyle}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
'$label :',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: valueStyle ?? AppTypography.bodyTextSmall,
),
),
],
);
}
}

View File

@@ -0,0 +1,513 @@
/// Dialog pour créer un budget
library create_budget_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../core/validation/validators.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../../../shared/widgets/validated_text_field.dart';
import '../../domain/entities/budget.dart';
import '../bloc/budget_bloc.dart';
import '../bloc/budget_event.dart';
class CreateBudgetDialog extends StatefulWidget {
final String organizationId;
const CreateBudgetDialog({
super.key,
required this.organizationId,
});
@override
State<CreateBudgetDialog> createState() => _CreateBudgetDialogState();
}
class _CreateBudgetDialogState extends State<CreateBudgetDialog> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _yearController = TextEditingController(
text: DateTime.now().year.toString(),
);
BudgetPeriod _selectedPeriod = BudgetPeriod.annual;
int? _selectedMonth;
// Budget lines
final List<_BudgetLineData> _budgetLines = [];
@override
void dispose() {
_nameController.dispose();
_descriptionController.dispose();
_yearController.dispose();
super.dispose();
}
void _addBudgetLine() {
setState(() {
_budgetLines.add(_BudgetLineData());
});
}
void _removeBudgetLine(int index) {
setState(() {
_budgetLines.removeAt(index);
});
}
void _submitForm() {
if (_formKey.currentState!.validate()) {
// Validate at least one budget line
if (_budgetLines.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Veuillez ajouter au moins une ligne budgétaire'),
backgroundColor: Colors.red,
),
);
return;
}
// Build budget lines
final lines = _budgetLines.map((line) {
return BudgetLine(
id: DateTime.now().millisecondsSinceEpoch.toString(),
category: line.category!,
name: line.nameController.text.trim(),
description: line.descriptionController.text.trim(),
amountPlanned: double.parse(line.amountController.text.trim()),
amountRealized: 0.0,
);
}).toList();
// Dispatch create budget event
context.read<BudgetBloc>().add(
CreateBudgetEvent(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
organizationId: widget.organizationId,
period: _selectedPeriod,
year: int.parse(_yearController.text.trim()),
month: _selectedMonth,
lines: lines,
),
);
Navigator.of(context).pop();
}
}
@override
Widget build(BuildContext context) {
return Dialog(
child: Container(
constraints: const BoxConstraints(maxWidth: 600),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Header
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: AppColors.primaryGreen,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(SpacingTokens.radiusMd),
topRight: Radius.circular(SpacingTokens.radiusMd),
),
),
child: Row(
children: [
const Icon(Icons.account_balance, color: Colors.white),
const SizedBox(width: SpacingTokens.sm),
const Text(
'Créer un budget',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close, color: Colors.white),
onPressed: () => Navigator.of(context).pop(),
),
],
),
),
// Content
Flexible(
child: SingleChildScrollView(
padding: const EdgeInsets.all(SpacingTokens.md),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Budget name
ValidatedTextField(
controller: _nameController,
labelText: 'Nom du budget *',
hintText: 'Ex: Budget annuel 2026',
validator: FinanceValidators.budgetName(),
textInputAction: TextInputAction.next,
),
const SizedBox(height: SpacingTokens.md),
// Description
ValidatedTextField(
controller: _descriptionController,
labelText: 'Description',
hintText: 'Description du budget...',
validator: FinanceValidators.budgetDescription(),
maxLines: 3,
textInputAction: TextInputAction.next,
),
const SizedBox(height: SpacingTokens.md),
// Period and Year
Row(
children: [
Expanded(
child: ValidatedDropdownField<BudgetPeriod>(
value: _selectedPeriod,
labelText: 'Période *',
items: BudgetPeriod.values.map((period) {
return DropdownMenuItem(
value: period,
child: Text(_getPeriodLabel(period)),
);
}).toList(),
validator: (value) {
if (value == null) {
return 'Période requise';
}
return null;
},
onChanged: (value) {
setState(() {
_selectedPeriod = value!;
if (_selectedPeriod != BudgetPeriod.monthly) {
_selectedMonth = null;
}
});
},
),
),
const SizedBox(width: SpacingTokens.sm),
Expanded(
child: ValidatedTextField(
controller: _yearController,
labelText: 'Année *',
hintText: 'Ex: 2026',
validator: FinanceValidators.fiscalYear(),
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
),
),
],
),
// Month (if monthly period)
if (_selectedPeriod == BudgetPeriod.monthly) ...[
const SizedBox(height: SpacingTokens.md),
ValidatedDropdownField<int>(
value: _selectedMonth,
labelText: 'Mois *',
items: List.generate(12, (index) {
final month = index + 1;
return DropdownMenuItem(
value: month,
child: Text(_getMonthLabel(month)),
);
}),
validator: (value) {
if (_selectedPeriod == BudgetPeriod.monthly &&
value == null) {
return 'Mois requis pour budget mensuel';
}
return null;
},
onChanged: (value) {
setState(() {
_selectedMonth = value;
});
},
),
],
const SizedBox(height: SpacingTokens.lg),
// Budget lines section
Row(
children: [
Text(
'Lignes budgétaires',
style: AppTypography.headerSmall.copyWith(
fontWeight: FontWeight.bold,
),
),
const Spacer(),
ElevatedButton.icon(
onPressed: _addBudgetLine,
icon: const Icon(Icons.add, size: 18),
label: const Text('Ajouter'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryGreen,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.md,
vertical: SpacingTokens.sm,
),
),
),
],
),
const SizedBox(height: SpacingTokens.sm),
// Budget lines list
if (_budgetLines.isEmpty)
Container(
padding: const EdgeInsets.all(SpacingTokens.lg),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius:
BorderRadius.circular(SpacingTokens.radiusSm),
border: Border.all(color: Colors.grey.shade300),
),
child: const Center(
child: Text(
'Aucune ligne budgétaire.\nCliquez sur "Ajouter" pour commencer.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
)
else
..._budgetLines.asMap().entries.map((entry) {
final index = entry.key;
final line = entry.value;
return _BudgetLineWidget(
key: ValueKey(line.id),
lineData: line,
onRemove: () => _removeBudgetLine(index),
);
}),
],
),
),
),
),
// Actions
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: Colors.grey.shade50,
border: Border(
top: BorderSide(color: Colors.grey.shade300),
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
const SizedBox(width: SpacingTokens.sm),
ElevatedButton.icon(
onPressed: _submitForm,
icon: const Icon(Icons.check),
label: const Text('Créer le budget'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.success,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: SpacingTokens.lg,
vertical: SpacingTokens.md,
),
),
),
],
),
),
],
),
),
);
}
String _getPeriodLabel(BudgetPeriod period) {
switch (period) {
case BudgetPeriod.monthly:
return 'Mensuel';
case BudgetPeriod.quarterly:
return 'Trimestriel';
case BudgetPeriod.semiannual:
return 'Semestriel';
case BudgetPeriod.annual:
return 'Annuel';
}
}
String _getMonthLabel(int month) {
const months = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
];
return months[month - 1];
}
}
/// Budget line data holder
class _BudgetLineData {
final String id;
final nameController = TextEditingController();
final descriptionController = TextEditingController();
final amountController = TextEditingController();
BudgetCategory? category;
_BudgetLineData() : id = DateTime.now().millisecondsSinceEpoch.toString();
void dispose() {
nameController.dispose();
descriptionController.dispose();
amountController.dispose();
}
}
/// Budget line widget
class _BudgetLineWidget extends StatefulWidget {
final _BudgetLineData lineData;
final VoidCallback onRemove;
const _BudgetLineWidget({
super.key,
required this.lineData,
required this.onRemove,
});
@override
State<_BudgetLineWidget> createState() => _BudgetLineWidgetState();
}
class _BudgetLineWidgetState extends State<_BudgetLineWidget> {
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.only(bottom: SpacingTokens.md),
elevation: 2,
child: Padding(
padding: const EdgeInsets.all(SpacingTokens.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.receipt_long, color: AppColors.primaryGreen),
const SizedBox(width: SpacingTokens.sm),
const Text(
'Ligne budgétaire',
style: TextStyle(fontWeight: FontWeight.bold),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: widget.onRemove,
tooltip: 'Supprimer',
),
],
),
const SizedBox(height: SpacingTokens.md),
// Category
ValidatedDropdownField<BudgetCategory>(
value: widget.lineData.category,
labelText: 'Catégorie *',
items: BudgetCategory.values.map((category) {
return DropdownMenuItem(
value: category,
child: Text(_getCategoryLabel(category)),
);
}).toList(),
validator: (value) {
if (value == null) {
return 'Catégorie requise';
}
return null;
},
onChanged: (value) {
setState(() {
widget.lineData.category = value;
});
},
),
const SizedBox(height: SpacingTokens.sm),
// Name
ValidatedTextField(
controller: widget.lineData.nameController,
labelText: 'Nom *',
hintText: 'Ex: Cotisations mensuelles',
validator: FinanceValidators.budgetLineName(),
textInputAction: TextInputAction.next,
),
const SizedBox(height: SpacingTokens.sm),
// Amount
ValidatedAmountField(
controller: widget.lineData.amountController,
labelText: 'Montant prévu *',
hintText: '0.00',
validator: FinanceValidators.amount(min: 0.01),
),
const SizedBox(height: SpacingTokens.sm),
// Description
ValidatedTextField(
controller: widget.lineData.descriptionController,
labelText: 'Description',
hintText: 'Description de la ligne...',
validator: FinanceValidators.budgetDescription(),
maxLines: 2,
),
],
),
),
);
}
String _getCategoryLabel(BudgetCategory category) {
switch (category) {
case BudgetCategory.contributions:
return 'Cotisations';
case BudgetCategory.savings:
return 'Épargne';
case BudgetCategory.solidarity:
return 'Solidarité';
case BudgetCategory.events:
return 'Événements';
case BudgetCategory.operational:
return 'Opérationnel';
case BudgetCategory.investments:
return 'Investissements';
case BudgetCategory.other:
return 'Autre';
}
}
}

View File

@@ -0,0 +1,173 @@
/// Dialog pour rejeter une transaction
library reject_dialog;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:intl/intl.dart';
import '../../../../core/validation/validators.dart';
import '../../../../shared/design_system/unionflow_design_system.dart';
import '../../domain/entities/transaction_approval.dart';
import '../bloc/approval_bloc.dart';
import '../bloc/approval_event.dart';
class RejectDialog extends StatefulWidget {
final TransactionApproval approval;
const RejectDialog({
super.key,
required this.approval,
});
@override
State<RejectDialog> createState() => _RejectDialogState();
}
class _RejectDialogState extends State<RejectDialog> {
final _reasonController = TextEditingController();
final _formKey = GlobalKey<FormState>();
@override
void dispose() {
_reasonController.dispose();
super.dispose();
}
String _getTransactionTypeLabel(TransactionType type) {
switch (type) {
case TransactionType.contribution:
return 'Cotisation';
case TransactionType.deposit:
return 'Dépôt';
case TransactionType.withdrawal:
return 'Retrait';
case TransactionType.transfer:
return 'Transfert';
case TransactionType.solidarity:
return 'Solidarité';
case TransactionType.event:
return 'Événement';
case TransactionType.other:
return 'Autre';
}
}
@override
Widget build(BuildContext context) {
final currencyFormat = NumberFormat.currency(symbol: widget.approval.currency);
return AlertDialog(
title: const Text('Rejeter la transaction'),
content: SingleChildScrollView(
child: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Vous êtes sur le point de rejeter cette transaction.',
style: AppTypography.bodyTextSmall.copyWith(
color: AppColors.error,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: SpacingTokens.md),
Container(
padding: const EdgeInsets.all(SpacingTokens.md),
decoration: BoxDecoration(
color: AppColors.lightBackground,
borderRadius: BorderRadius.circular(SpacingTokens.radiusSm),
border: Border.all(color: AppColors.lightBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildInfoRow(
'Type',
_getTransactionTypeLabel(widget.approval.transactionType),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Montant',
currencyFormat.format(widget.approval.amount),
valueStyle: AppTypography.actionText.copyWith(
color: AppColors.primaryGreen,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: SpacingTokens.sm),
_buildInfoRow(
'Demandeur',
widget.approval.requesterName,
),
],
),
),
const SizedBox(height: SpacingTokens.md),
TextFormField(
controller: _reasonController,
decoration: const InputDecoration(
labelText: 'Raison du rejet *',
hintText: 'Expliquez la raison du rejet...',
border: OutlineInputBorder(),
helperText: 'Minimum 10 caractères, maximum 500',
),
maxLines: 4,
maxLength: 500,
validator: FinanceValidators.rejectionReason(),
),
],
),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Annuler'),
),
ElevatedButton.icon(
onPressed: () {
if (_formKey.currentState!.validate()) {
context.read<ApprovalBloc>().add(
RejectTransactionEvent(
approvalId: widget.approval.id,
reason: _reasonController.text.trim(),
),
);
Navigator.of(context).pop();
}
},
icon: const Icon(Icons.close),
label: const Text('Rejeter'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.error,
foregroundColor: Colors.white,
),
),
],
);
}
Widget _buildInfoRow(String label, String value, {TextStyle? valueStyle}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
'$label :',
style: AppTypography.subtitleSmall.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Expanded(
child: Text(
value,
style: valueStyle ?? AppTypography.bodyTextSmall,
),
),
],
);
}
}