Initial commit: unionflow-mobile-apps
Application Flutter complète (sans build artifacts). Signed-off-by: lions dev Team
This commit is contained in:
@@ -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];
|
||||
}
|
||||
187
lib/features/finance_workflow/presentation/bloc/budget_bloc.dart
Normal file
187
lib/features/finance_workflow/presentation/bloc/budget_bloc.dart
Normal file
@@ -0,0 +1,187 @@
|
||||
/// BLoC pour la gestion des budgets
|
||||
library budget_bloc;
|
||||
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import '../../domain/usecases/get_budgets.dart';
|
||||
import '../../domain/usecases/get_budget_by_id.dart';
|
||||
import '../../domain/usecases/create_budget.dart';
|
||||
import '../../domain/usecases/get_budget_tracking.dart';
|
||||
import 'budget_event.dart';
|
||||
import 'budget_state.dart';
|
||||
|
||||
@injectable
|
||||
class BudgetBloc extends Bloc<BudgetEvent, BudgetState> {
|
||||
final GetBudgets getBudgets;
|
||||
final GetBudgetById getBudgetById;
|
||||
final CreateBudget createBudget;
|
||||
final GetBudgetTracking getBudgetTracking;
|
||||
|
||||
BudgetBloc({
|
||||
required this.getBudgets,
|
||||
required this.getBudgetById,
|
||||
required this.createBudget,
|
||||
required this.getBudgetTracking,
|
||||
}) : super(const BudgetInitial()) {
|
||||
on<LoadBudgets>(_onLoadBudgets);
|
||||
on<LoadBudgetById>(_onLoadBudgetById);
|
||||
on<CreateBudgetEvent>(_onCreateBudget);
|
||||
on<LoadBudgetTracking>(_onLoadBudgetTracking);
|
||||
on<RefreshBudgets>(_onRefreshBudgets);
|
||||
on<FilterBudgets>(_onFilterBudgets);
|
||||
}
|
||||
|
||||
Future<void> _onLoadBudgets(
|
||||
LoadBudgets event,
|
||||
Emitter<BudgetState> emit,
|
||||
) async {
|
||||
emit(const BudgetsLoading());
|
||||
|
||||
final result = await getBudgets(
|
||||
organizationId: event.organizationId,
|
||||
status: event.status,
|
||||
year: event.year,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(BudgetError(failure.message)),
|
||||
(budgets) {
|
||||
if (budgets.isEmpty) {
|
||||
emit(const BudgetsEmpty());
|
||||
} else {
|
||||
emit(BudgetsLoaded(
|
||||
budgets: budgets,
|
||||
filterStatus: event.status,
|
||||
filterYear: event.year,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onLoadBudgetById(
|
||||
LoadBudgetById event,
|
||||
Emitter<BudgetState> emit,
|
||||
) async {
|
||||
emit(const BudgetsLoading());
|
||||
|
||||
final result = await getBudgetById(event.budgetId);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(BudgetError(failure.message)),
|
||||
(budget) => emit(BudgetDetailLoaded(budget)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onCreateBudget(
|
||||
CreateBudgetEvent event,
|
||||
Emitter<BudgetState> emit,
|
||||
) async {
|
||||
emit(const BudgetActionInProgress('create'));
|
||||
|
||||
final result = await createBudget(
|
||||
name: event.name,
|
||||
description: event.description,
|
||||
organizationId: event.organizationId,
|
||||
period: event.period,
|
||||
year: event.year,
|
||||
month: event.month,
|
||||
lines: event.lines,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(BudgetError(failure.message)),
|
||||
(budget) => emit(BudgetCreated(budget: budget)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onLoadBudgetTracking(
|
||||
LoadBudgetTracking event,
|
||||
Emitter<BudgetState> emit,
|
||||
) async {
|
||||
emit(const BudgetsLoading());
|
||||
|
||||
// Load budget first
|
||||
final budgetResult = await getBudgetById(event.budgetId);
|
||||
|
||||
await budgetResult.fold(
|
||||
(failure) async => emit(BudgetError(failure.message)),
|
||||
(budget) async {
|
||||
// Then load tracking
|
||||
final trackingResult = await getBudgetTracking(budgetId: event.budgetId);
|
||||
|
||||
trackingResult.fold(
|
||||
(failure) => emit(BudgetError(failure.message)),
|
||||
(tracking) => emit(BudgetTrackingLoaded(
|
||||
budget: budget,
|
||||
tracking: tracking,
|
||||
)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onRefreshBudgets(
|
||||
RefreshBudgets event,
|
||||
Emitter<BudgetState> emit,
|
||||
) async {
|
||||
final result = await getBudgets(
|
||||
organizationId: event.organizationId,
|
||||
status: event.status,
|
||||
year: event.year,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(BudgetError(failure.message)),
|
||||
(budgets) {
|
||||
if (budgets.isEmpty) {
|
||||
emit(const BudgetsEmpty());
|
||||
} else {
|
||||
emit(BudgetsLoaded(
|
||||
budgets: budgets,
|
||||
filterStatus: event.status,
|
||||
filterYear: event.year,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _onFilterBudgets(
|
||||
FilterBudgets event,
|
||||
Emitter<BudgetState> emit,
|
||||
) async {
|
||||
// Keep current organization if in loaded state
|
||||
String? organizationId;
|
||||
if (state is BudgetsLoaded) {
|
||||
final currentState = state as BudgetsLoaded;
|
||||
// Extract org ID from first budget if available
|
||||
if (currentState.budgets.isNotEmpty) {
|
||||
organizationId = currentState.budgets.first.organizationId;
|
||||
}
|
||||
}
|
||||
|
||||
emit(const BudgetsLoading());
|
||||
|
||||
final result = await getBudgets(
|
||||
organizationId: organizationId,
|
||||
status: event.status,
|
||||
year: event.year,
|
||||
);
|
||||
|
||||
result.fold(
|
||||
(failure) => emit(BudgetError(failure.message)),
|
||||
(budgets) {
|
||||
if (budgets.isEmpty) {
|
||||
emit(const BudgetsEmpty());
|
||||
} else {
|
||||
emit(BudgetsLoaded(
|
||||
budgets: budgets,
|
||||
filterStatus: event.status,
|
||||
filterYear: event.year,
|
||||
));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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