feat: WebSocket temps réel + Finance Workflow + corrections
- Task #6: WebSocket /ws/dashboard + Kafka events (5 topics) * Backend: KafkaEventProducer, KafkaEventConsumer * Mobile: WebSocketService (reconnection, heartbeat, typed events) * DashboardBloc: Auto-refresh depuis WebSocket events - Finance Workflow: approbations + budgets (backend + mobile) * Backend: entities, services, resources, migrations Flyway V6 * Mobile: features finance_workflow complète avec BLoC - Corrections DI: interfaces IRepository partout * IProfileRepository, IOrganizationRepository, IMembreRepository * GetIt configuré avec @injectable - Spec-Kit: constitution + templates mis à jour * .specify/memory/constitution.md enrichie * Templates agent, plan, spec, tasks, checklist - Nettoyage: fichiers temporaires supprimés Signed-off-by: lions dev Team
This commit is contained in:
@@ -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
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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é';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user