2241 lines
72 KiB
Dart
2241 lines
72 KiB
Dart
// ===== lib/pages/rechargeur_dashboard.dart =====
|
|
import 'dart:convert';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:http/http.dart' as http;
|
|
import 'package:provider/provider.dart';
|
|
import '../main.dart';
|
|
import '../widgets/wortis_logo.dart';
|
|
import 'login.dart';
|
|
|
|
// ===== CONFIGURATION API =====
|
|
const String baseUrl = 'https://api.live.wortis.cg/tpe';
|
|
const String apiBaseUrl = '$baseUrl/api';
|
|
const Map<String, String> apiHeaders = {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
};
|
|
|
|
// ===== COULEURS GLOBALES =====
|
|
class RechargeurColors {
|
|
static const Color primary = Color(0xFF38A169);
|
|
static const Color secondary = Color(0xFF006699);
|
|
static const Color accent = Color(0xFFFF6B35);
|
|
static const Color background = Color(0xFFF8FAFC);
|
|
static const Color surface = Colors.white;
|
|
static const Color warning = Color(0xFFF59E0B);
|
|
static const Color success = Color(0xFF38A169);
|
|
static const Color error = Colors.red;
|
|
}
|
|
|
|
// ===== RECHARGEUR DASHBOARD =====
|
|
class RechargeurDashboard extends StatefulWidget {
|
|
const RechargeurDashboard({super.key});
|
|
|
|
@override
|
|
State<RechargeurDashboard> createState() => _RechargeurDashboardState();
|
|
}
|
|
|
|
class _RechargeurDashboardState extends State<RechargeurDashboard>
|
|
with SingleTickerProviderStateMixin {
|
|
late AnimationController _animationController;
|
|
late Animation<double> _fadeAnimation;
|
|
|
|
bool _isLoading = false;
|
|
List<Map<String, dynamic>> _pendingRecharges = [];
|
|
Map<String, dynamic> _rechargeStats = {};
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_setupAnimations();
|
|
_loadPendingRecharges();
|
|
_loadRechargeStats(); // Ajoutez cette ligne
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _setupAnimations() {
|
|
_animationController = AnimationController(
|
|
duration: const Duration(milliseconds: 1000),
|
|
vsync: this,
|
|
);
|
|
|
|
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
CurvedAnimation(parent: _animationController, curve: Curves.easeOut),
|
|
);
|
|
|
|
_animationController.forward();
|
|
}
|
|
|
|
// ===== MÉTHODES API =====
|
|
|
|
Future<bool> _approveRechargeApi(
|
|
String rechargeId,
|
|
String agentId,
|
|
String pin,
|
|
) async {
|
|
try {
|
|
final response = await http.put(
|
|
Uri.parse('$baseUrl/recharge/approve/$rechargeId/agent/$agentId'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'status': 'approved',
|
|
'approved_by':
|
|
Provider.of<AuthController>(context, listen: false).agentId,
|
|
'approved_at': DateTime.now().toIso8601String(),
|
|
'pin': pin,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
return data['success'] ?? false;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
print('❌ Erreur approbation recharge: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<bool> _rejectRechargeApi(
|
|
String rechargeId,
|
|
String agentId,
|
|
String pin, {
|
|
String? reason,
|
|
}) async {
|
|
try {
|
|
final response = await http.put(
|
|
Uri.parse('$baseUrl/recharge/reject/$rechargeId/agent/$agentId'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'status': 'rejected',
|
|
'rejected_by':
|
|
Provider.of<AuthController>(context, listen: false).agentId,
|
|
'rejected_at': DateTime.now().toIso8601String(),
|
|
'rejection_reason': reason ?? 'Demande rejetée par le rechargeur',
|
|
'pin': pin,
|
|
}),
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
return data['success'] ?? false;
|
|
}
|
|
return false;
|
|
} catch (e) {
|
|
print('❌ Erreur rejet recharge: $e');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _processRechargeApi(
|
|
String agentId,
|
|
double amount,
|
|
String pin,
|
|
) async {
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl/recharge/process'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'agent_id': agentId,
|
|
'montant': amount,
|
|
'rechargeur_id':
|
|
Provider.of<AuthController>(context, listen: false).agentId,
|
|
'processed_at': DateTime.now().toIso8601String(),
|
|
'pin': pin,
|
|
}),
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
return {
|
|
'success':
|
|
response.statusCode == 200 ? (data['success'] ?? false) : false,
|
|
'message': data['message'] ?? '',
|
|
'transaction_id': data['transaction_id'],
|
|
'new_balance': data['new_balance'],
|
|
'error_code': response.statusCode != 200 ? response.statusCode : null,
|
|
};
|
|
} catch (e) {
|
|
print('❌ Erreur traitement recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
|
|
Future<void> _loadPendingRecharges() async {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final recharges = await RechargeApiService.getAllRecharges();
|
|
setState(() {
|
|
_pendingRecharges =
|
|
recharges.where((r) => r['status'] == 'pending').toList();
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
_showSnackBar(
|
|
'Erreur lors du chargement des recharges',
|
|
RechargeurColors.error,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadRechargeStats() async {
|
|
try {
|
|
final authController = Provider.of<AuthController>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
final stats = await RechargeApiService.getRechargeStats(
|
|
rechargeurId: authController.agentId,
|
|
startDate: DateTime.now().subtract(const Duration(days: 30)),
|
|
endDate: DateTime.now(),
|
|
);
|
|
print('**************************************$stats');
|
|
if (stats['success'] == true) {
|
|
setState(() {
|
|
_rechargeStats = stats['stats'] ?? {};
|
|
});
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur chargement statistiques: $e');
|
|
}
|
|
}
|
|
|
|
// ===== MÉTHODES UI =====
|
|
void _logout() async {
|
|
final result = await showDialog<bool>(
|
|
context: context,
|
|
builder: (BuildContext context) {
|
|
return AlertDialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.logout, color: RechargeurColors.accent),
|
|
SizedBox(width: 12),
|
|
Text('Déconnexion'),
|
|
],
|
|
),
|
|
content: const Text('Êtes-vous sûr de vouloir vous déconnecter ?'),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: RechargeurColors.accent,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Déconnexion'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (result == true) {
|
|
final authController = Provider.of<AuthController>(
|
|
context,
|
|
listen: false,
|
|
);
|
|
await authController.logout();
|
|
if (mounted) {
|
|
Navigator.of(context).pushAndRemoveUntil(
|
|
MaterialPageRoute(builder: (context) => const LoginPage()),
|
|
(route) => false,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showSnackBar(String message, Color color) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: color,
|
|
behavior: SnackBarBehavior.floating,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showRechargeDialog({
|
|
String? initialAgentId,
|
|
String? initialAmount,
|
|
String? errorMessage,
|
|
}) {
|
|
final agentIdController = TextEditingController(text: initialAgentId ?? '');
|
|
final amountController = TextEditingController(text: initialAmount ?? '');
|
|
final pinController = TextEditingController();
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final maxWidth =
|
|
constraints.maxWidth > 400 ? 400.0 : constraints.maxWidth;
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: ConstrainedBox(
|
|
constraints: BoxConstraints(maxWidth: maxWidth),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: const [
|
|
Icon(
|
|
Icons.add_circle,
|
|
color: RechargeurColors.primary,
|
|
),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Recharger un agent',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
if (errorMessage != null) ...[
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: Colors.red.withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.error_outline,
|
|
color: Colors.red,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
errorMessage,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: Colors.red,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
],
|
|
|
|
TextField(
|
|
controller: agentIdController,
|
|
decoration: InputDecoration(
|
|
labelText: 'ID Agent',
|
|
hintText: 'WRT1234',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(
|
|
Icons.person,
|
|
color: RechargeurColors.primary,
|
|
),
|
|
errorText:
|
|
errorMessage?.contains('Agent') == true
|
|
? 'ID Agent invalide'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
TextField(
|
|
controller: amountController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: 'Montant (FCFA)',
|
|
hintText: '50000',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(
|
|
Icons.monetization_on,
|
|
color: RechargeurColors.primary,
|
|
),
|
|
errorText:
|
|
errorMessage?.contains('Montant') == true
|
|
? 'Montant invalide'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
TextField(
|
|
controller: pinController,
|
|
obscureText: true,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 4,
|
|
decoration: InputDecoration(
|
|
labelText: 'Code PIN',
|
|
hintText: '****',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(
|
|
Icons.lock,
|
|
color: RechargeurColors.primary,
|
|
),
|
|
counterText: '',
|
|
errorText:
|
|
errorMessage?.contains('PIN') == true
|
|
? 'PIN invalide'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.primary.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.info_outline,
|
|
color: RechargeurColors.primary,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'La recharge sera effectuée immédiatement après validation du PIN',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: RechargeurColors.primary.withOpacity(
|
|
0.8,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.pop(context);
|
|
_processRecharge(
|
|
agentIdController.text,
|
|
amountController.text,
|
|
pinController.text,
|
|
);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: RechargeurColors.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 12,
|
|
),
|
|
),
|
|
child: const Text('Recharger'),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _processRecharge(
|
|
String agentId,
|
|
String amount,
|
|
String pin,
|
|
) async {
|
|
String? errorMessage;
|
|
|
|
// Validation des champs
|
|
if (agentId.isEmpty) {
|
|
errorMessage = 'L\'ID de l\'agent est requis';
|
|
} else if (amount.isEmpty) {
|
|
errorMessage = 'Le montant est requis';
|
|
} else if (pin.isEmpty) {
|
|
errorMessage = 'Le code PIN est requis';
|
|
} else if (pin.length != 4) {
|
|
errorMessage = 'Le PIN doit contenir exactement 4 chiffres';
|
|
} else if (!RegExp(r'^\d{4}$').hasMatch(pin)) {
|
|
errorMessage = 'Le PIN ne doit contenir que des chiffres';
|
|
}
|
|
|
|
final numericAmount = double.tryParse(amount);
|
|
if (errorMessage == null) {
|
|
if (numericAmount == null || numericAmount <= 0) {
|
|
errorMessage = 'Le montant doit être un nombre positif';
|
|
} else if (numericAmount > 10000000) {
|
|
errorMessage = 'Le montant ne peut pas dépasser 10,000,000 FCFA';
|
|
} else if (numericAmount < 1) {
|
|
errorMessage = 'Le montant minimum est de 1 FCFA';
|
|
}
|
|
}
|
|
|
|
if (!RegExp(r'^[A-Z0-9]{3,10}$').hasMatch(agentId) &&
|
|
errorMessage == null) {
|
|
errorMessage =
|
|
'L\'ID agent doit contenir 3-10 caractères alphanumériques en majuscules';
|
|
}
|
|
|
|
// Si erreur de validation, réouvrir le dialog
|
|
if (errorMessage != null) {
|
|
_showRechargeDialog(
|
|
initialAgentId: agentId,
|
|
initialAmount: amount,
|
|
errorMessage: errorMessage,
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final result = await _processRechargeApi(agentId, numericAmount!, pin);
|
|
|
|
if (result['success'] == true) {
|
|
_showSnackBar(
|
|
'✅ Recharge de ${numericAmount.toStringAsFixed(0)} XAF effectuée pour $agentId',
|
|
RechargeurColors.success,
|
|
);
|
|
_loadPendingRecharges();
|
|
_loadRechargeStats();
|
|
} else {
|
|
String apiErrorMessage =
|
|
result['message'] ?? 'Erreur lors de la recharge';
|
|
|
|
// Réouvrir le dialog avec l'erreur API
|
|
_showRechargeDialog(
|
|
initialAgentId: agentId,
|
|
initialAmount: amount,
|
|
errorMessage: apiErrorMessage,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_showRechargeDialog(
|
|
initialAgentId: agentId,
|
|
initialAmount: amount,
|
|
errorMessage: 'Erreur de connexion',
|
|
);
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _approveRecharge(Map<String, dynamic> recharge) async {
|
|
final rechargeId =
|
|
recharge['_id'] is Map
|
|
? recharge['_id']['\$oid'] ?? recharge['_id'].toString()
|
|
: recharge['_id'].toString();
|
|
final agentId = recharge['agent_id'];
|
|
final amount = recharge['montant'];
|
|
|
|
final pinController = TextEditingController();
|
|
|
|
final result = await showDialog<String>(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.security, color: Colors.green),
|
|
SizedBox(width: 8),
|
|
Text('Confirmer avec PIN'),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'Approuver la recharge de $amount XAF pour l\'agent $agentId',
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: pinController,
|
|
obscureText: true,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 4,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Code PIN',
|
|
hintText: '****',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.lock, color: Colors.green),
|
|
counterText: '',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context, pinController.text),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.green,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Approuver'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result != null && result.isNotEmpty) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final success = await _approveRechargeApi(rechargeId, agentId, result);
|
|
|
|
if (success) {
|
|
_showSnackBar(
|
|
'✅ Recharge approuvée: $agentId - $amount XAF',
|
|
Colors.green,
|
|
);
|
|
_loadPendingRecharges();
|
|
_loadRechargeStats();
|
|
} else {
|
|
_showSnackBar(
|
|
'PIN incorrect ou erreur lors de l\'approbation',
|
|
RechargeurColors.error,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_showSnackBar('Erreur lors de l\'approbation', RechargeurColors.error);
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showHistoryDialog() {
|
|
showDialog(
|
|
context: context,
|
|
builder:
|
|
(context) => Dialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
child: Container(
|
|
width: MediaQuery.of(context).size.width * 0.9,
|
|
height: MediaQuery.of(context).size.height * 0.8,
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text(
|
|
'Historique des recharges',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
icon: const Icon(Icons.close),
|
|
),
|
|
],
|
|
),
|
|
const Divider(),
|
|
Expanded(
|
|
child: FutureBuilder<Map<String, dynamic>>(
|
|
future: RechargeApiService.getRechargeHistory(
|
|
rechargeurId:
|
|
Provider.of<AuthController>(
|
|
context,
|
|
listen: false,
|
|
).agentId,
|
|
),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState ==
|
|
ConnectionState.waiting) {
|
|
return const Center(
|
|
child: CircularProgressIndicator(),
|
|
);
|
|
}
|
|
|
|
if (!snapshot.hasData ||
|
|
snapshot.data!['success'] != true) {
|
|
return const Center(
|
|
child: Text(
|
|
'Erreur lors du chargement de l\'historique',
|
|
),
|
|
);
|
|
}
|
|
|
|
final recharges = List<Map<String, dynamic>>.from(
|
|
snapshot.data!['recharges'] ?? [],
|
|
);
|
|
|
|
if (recharges.isEmpty) {
|
|
return const Center(
|
|
child: Text('Aucune recharge dans l\'historique'),
|
|
);
|
|
}
|
|
|
|
return ListView.builder(
|
|
itemCount: recharges.length,
|
|
itemBuilder: (context, index) {
|
|
final recharge = recharges[index];
|
|
return _buildHistoryItem(recharge);
|
|
},
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHistoryItem(Map<String, dynamic> recharge) {
|
|
final agentId = recharge['agent_id'] ?? '';
|
|
final amount = recharge['montant'] ?? 0.0;
|
|
final amountValue = double.tryParse(amount.toString()) ?? 0.0;
|
|
final status = recharge['status'] ?? '';
|
|
final date =
|
|
DateTime.tryParse(recharge['created_at']['\$date'] ?? '') ??
|
|
DateTime.now();
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 8),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey[200]!),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: RechargeUtils.getStatusColor(status).withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
RechargeUtils.getStatusIcon(status),
|
|
color: RechargeUtils.getStatusColor(status),
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Agent $agentId',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Text(
|
|
'${amountValue.toStringAsFixed(0)} XAF • ${RechargeUtils.formatRelativeDate(date)}',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: RechargeUtils.getStatusColor(status).withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
status.toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: RechargeUtils.getStatusColor(status),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _rejectRecharge(Map<String, dynamic> recharge) async {
|
|
final rechargeId =
|
|
recharge['_id'] is Map
|
|
? recharge['_id']['\$oid'] ?? recharge['_id'].toString()
|
|
: recharge['_id'].toString();
|
|
final agentId = recharge['agent_id'];
|
|
final reasonController = TextEditingController();
|
|
final pinController = TextEditingController();
|
|
|
|
final result = await showDialog<Map<String, String>>(
|
|
context: context,
|
|
builder:
|
|
(context) => AlertDialog(
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(16),
|
|
),
|
|
title: const Row(
|
|
children: [
|
|
Icon(Icons.security, color: Colors.red),
|
|
SizedBox(width: 8),
|
|
Text('Confirmer le rejet'),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text('Rejeter la demande de recharge pour l\'agent $agentId'),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: reasonController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Raison du rejet (optionnel)',
|
|
hintText: 'Ex: Solde insuffisant',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
maxLines: 2,
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: pinController,
|
|
obscureText: true,
|
|
keyboardType: TextInputType.number,
|
|
maxLength: 4,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Code PIN',
|
|
hintText: '****',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.lock, color: Colors.red),
|
|
counterText: '',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed:
|
|
() => Navigator.pop(context, {
|
|
'reason': reasonController.text,
|
|
'pin': pinController.text,
|
|
}),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
child: const Text('Rejeter'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (result != null && result['pin']!.isNotEmpty) {
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final success = await _rejectRechargeApi(
|
|
rechargeId,
|
|
agentId,
|
|
result['pin']!,
|
|
reason: result['reason']!.isEmpty ? null : result['reason'],
|
|
);
|
|
|
|
if (success) {
|
|
_showSnackBar('❌ Recharge rejetée pour $agentId', Colors.red);
|
|
_loadPendingRecharges();
|
|
_loadRechargeStats(); // Ajoutez cette ligne
|
|
} else {
|
|
_showSnackBar(
|
|
'PIN incorrect ou erreur lors du rejet',
|
|
RechargeurColors.error,
|
|
);
|
|
}
|
|
} catch (e) {
|
|
_showSnackBar('Erreur lors du rejet', RechargeurColors.error);
|
|
} finally {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
backgroundColor: RechargeurColors.background,
|
|
body: FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: SafeArea(
|
|
child: RefreshIndicator(
|
|
onRefresh: () async {
|
|
await _loadPendingRecharges();
|
|
await _loadRechargeStats(); // Ajoutez cette ligne
|
|
},
|
|
child: SingleChildScrollView(
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildHeader(),
|
|
const SizedBox(height: 30),
|
|
_buildRechargeStats(),
|
|
const SizedBox(height: 30),
|
|
_buildRechargeActions(),
|
|
const SizedBox(height: 30),
|
|
_buildPendingRecharges(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildHeader() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(24),
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
RechargeurColors.primary,
|
|
RechargeurColors.primary.withOpacity(0.8),
|
|
],
|
|
),
|
|
borderRadius: BorderRadius.circular(20),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: RechargeurColors.primary.withOpacity(0.3),
|
|
blurRadius: 15,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: Consumer<AuthController>(
|
|
builder: (context, authController, child) {
|
|
return Row(
|
|
children: [
|
|
const WortisLogoWidget(
|
|
size: 60,
|
|
isWhite: true,
|
|
withShadow: false,
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(
|
|
Icons.add_circle,
|
|
color: Colors.white,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'ESPACE RECHARGEUR',
|
|
style: TextStyle(
|
|
color: Colors.white.withOpacity(0.9),
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
authController.agentName ?? 'Rechargeur',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 4,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white.withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'Solde: ${authController.activeBalance.toStringAsFixed(0)} XAF',
|
|
style: const TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
IconButton(
|
|
onPressed: _logout,
|
|
icon: const Icon(Icons.logout, color: Colors.white),
|
|
tooltip: 'Déconnexion',
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRechargeStats() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Statistiques de recharge',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// === STATISTIQUES GLOBALES ===
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Total recharges',
|
|
'${_rechargeStats['total_recharges'] ?? 0}',
|
|
Icons.receipt_long,
|
|
RechargeurColors.primary,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Volume total',
|
|
'${(_rechargeStats['total_amount'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.account_balance_wallet,
|
|
RechargeurColors.secondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// === STATISTIQUES DE SUCCÈS ===
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Recharges réussies',
|
|
'${_rechargeStats['success_count'] ?? 0}',
|
|
Icons.check_circle,
|
|
RechargeurColors.success,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Volume réussi',
|
|
'${(_rechargeStats['success_amount'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.trending_up,
|
|
RechargeurColors.success,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// === TAUX ET MOYENNES ===
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Taux de réussite',
|
|
'${(_rechargeStats['success_rate'] ?? 0.0).toStringAsFixed(1)}%',
|
|
Icons.analytics,
|
|
Colors.blue,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Montant moyen',
|
|
'${(_rechargeStats['average_amount'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.calculate,
|
|
Colors.orange,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// === STATUT ACTUEL ===
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'En attente',
|
|
'${_rechargeStats['pending_count'] ?? 0}',
|
|
Icons.pending,
|
|
RechargeurColors.warning,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: _buildStatCard(
|
|
'Rejetées',
|
|
'${_rechargeStats['rejected_count'] ?? 0}',
|
|
Icons.cancel,
|
|
RechargeurColors.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// === SECTION AUJOURD'HUI ===
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.blue.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.today, color: Colors.blue, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Aujourd\'hui (${DateTime.now().day}/${DateTime.now().month})',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.blue,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Recharges',
|
|
'${_rechargeStats['daily_count'] ?? 0}',
|
|
Icons.receipt,
|
|
Colors.blue,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Volume',
|
|
'${(_rechargeStats['daily_volume'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.monetization_on,
|
|
Colors.green,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Réussies',
|
|
'${_rechargeStats['daily_success'] ?? 0}',
|
|
Icons.check,
|
|
Colors.green,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Taux',
|
|
'${(_rechargeStats['daily_success_rate'] ?? 0.0).toStringAsFixed(1)}%',
|
|
Icons.percent,
|
|
Colors.blue,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Moyenne',
|
|
'${(_rechargeStats['daily_average'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.trending_up,
|
|
Colors.orange,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'En attente',
|
|
'${_rechargeStats['daily_pending'] ?? 0}',
|
|
Icons.pending,
|
|
Colors.orange,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// === SECTION CE MOIS ===
|
|
Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.purple.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: Colors.purple.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.calendar_month, color: Colors.purple, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Ce mois (${_getMonthName(DateTime.now().month)} ${DateTime.now().year})',
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.purple,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Recharges',
|
|
'${_rechargeStats['monthly_count'] ?? 0}',
|
|
Icons.receipt,
|
|
Colors.purple,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Volume',
|
|
'${(_rechargeStats['monthly_volume'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.account_balance_wallet,
|
|
Colors.green,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Réussies',
|
|
'${_rechargeStats['monthly_success'] ?? 0}',
|
|
Icons.verified,
|
|
Colors.green,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Taux',
|
|
'${(_rechargeStats['monthly_success_rate'] ?? 0.0).toStringAsFixed(1)}%',
|
|
Icons.analytics,
|
|
Colors.blue,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Moyenne',
|
|
'${(_rechargeStats['monthly_average'] ?? 0.0).toStringAsFixed(0)} XAF',
|
|
Icons.calculate,
|
|
Colors.orange,
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildMiniStatCard(
|
|
'Échecs',
|
|
'${_rechargeStats['monthly_failed'] ?? 0}',
|
|
Icons.error,
|
|
Colors.red,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// Widget pour les mini-cartes de statistiques
|
|
Widget _buildMiniStatCard(
|
|
String title,
|
|
String value,
|
|
IconData icon,
|
|
Color color,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: color.withOpacity(0.3)),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Icon(icon, color: color, size: 16),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
Text(
|
|
title,
|
|
style: TextStyle(fontSize: 10, color: Colors.grey[600]),
|
|
textAlign: TextAlign.center,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Fonction utilitaire pour obtenir le nom du mois
|
|
String _getMonthName(int month) {
|
|
const months = [
|
|
'',
|
|
'Janvier',
|
|
'Février',
|
|
'Mars',
|
|
'Avril',
|
|
'Mai',
|
|
'Juin',
|
|
'Juillet',
|
|
'Août',
|
|
'Septembre',
|
|
'Octobre',
|
|
'Novembre',
|
|
'Décembre',
|
|
];
|
|
return months[month];
|
|
}
|
|
|
|
Widget _buildStatCard(
|
|
String title,
|
|
String value,
|
|
IconData icon,
|
|
Color color,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.surface,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: color.withOpacity(0.1)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withOpacity(0.08),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Icon(icon, color: color, size: 24),
|
|
const Text(
|
|
'↗ +8%',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.green,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRechargeActions() {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Actions de recharge',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildActionButton(
|
|
'Recharger un agent',
|
|
'Créditer le solde',
|
|
Icons.person_add,
|
|
RechargeurColors.primary,
|
|
onTap: _showRechargeDialog,
|
|
),
|
|
),
|
|
// const SizedBox(width: 12),
|
|
// Expanded(
|
|
// child: _buildActionButton(
|
|
// 'Scanner QR Code',
|
|
// 'Recharge rapide',
|
|
// Icons.qr_code_scanner,
|
|
// RechargeurColors.secondary,
|
|
// ),
|
|
// ),
|
|
],
|
|
),
|
|
// const SizedBox(height: 12),
|
|
// Row(
|
|
// children: [
|
|
// Expanded(
|
|
// child: _buildActionButton(
|
|
// 'Historique',
|
|
// 'Voir toutes les recharges',
|
|
// Icons.history,
|
|
// Colors.grey[600]!,
|
|
// onTap: _showHistoryDialog, // Ajoutez cette ligne
|
|
// ),
|
|
// )
|
|
// ],
|
|
// ),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildActionButton(
|
|
String title,
|
|
String subtitle,
|
|
IconData icon,
|
|
Color color, {
|
|
VoidCallback? onTap,
|
|
}) {
|
|
return GestureDetector(
|
|
onTap:
|
|
onTap ??
|
|
() =>
|
|
_showSnackBar('$title - Fonction en développement', Colors.blue),
|
|
child: Container(
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.surface,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: color.withOpacity(0.2)),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: color.withOpacity(0.1),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, color: color, size: 28),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
subtitle,
|
|
style: TextStyle(fontSize: 11, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPendingRecharges() {
|
|
return Container(
|
|
padding: const EdgeInsets.all(20),
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.surface,
|
|
borderRadius: BorderRadius.circular(16),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.05),
|
|
blurRadius: 10,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text(
|
|
'Recharges en attente',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.warning.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
'${_pendingRecharges.length} demandes',
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
color: RechargeurColors.warning,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_isLoading)
|
|
const Center(child: CircularProgressIndicator())
|
|
else if (_pendingRecharges.isEmpty)
|
|
const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(20),
|
|
child: Text(
|
|
'Aucune recharge en attente',
|
|
style: TextStyle(color: Colors.grey),
|
|
),
|
|
),
|
|
)
|
|
else
|
|
..._pendingRecharges.map((recharge) => _buildPendingItem(recharge)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildPendingItem(Map<String, dynamic> recharge) {
|
|
final agentId = recharge['agent_id'] ?? '';
|
|
final amount = recharge['montant'] ?? '';
|
|
final status = recharge['status'] ?? '';
|
|
|
|
return Container(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.warning.withOpacity(0.05),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: RechargeurColors.warning.withOpacity(0.2)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: RechargeurColors.warning.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(
|
|
Icons.person,
|
|
color: RechargeurColors.warning,
|
|
size: 20,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Agent $agentId',
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.black87,
|
|
),
|
|
),
|
|
Text(
|
|
'Demande: $amount XAF • ${status.toUpperCase()}',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: () => _approveRecharge(recharge),
|
|
icon: const Icon(
|
|
Icons.check_circle,
|
|
color: Colors.green,
|
|
size: 20,
|
|
),
|
|
tooltip: 'Approuver',
|
|
),
|
|
IconButton(
|
|
onPressed: () => _rejectRecharge(recharge),
|
|
icon: const Icon(Icons.cancel, color: Colors.red, size: 20),
|
|
tooltip: 'Rejeter',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ===== SERVICES API ADDITIONNELS =====
|
|
|
|
/// Service pour gérer les recharges
|
|
class RechargeApiService {
|
|
/// Récupérer toutes les recharges
|
|
static Future<List<Map<String, dynamic>>> getAllRecharges() async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('$baseUrl/recharge/get_recharges'),
|
|
headers: apiHeaders,
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
return List<Map<String, dynamic>>.from(data['all_recharges'] ?? []);
|
|
} else {
|
|
throw Exception('Erreur HTTP ${response.statusCode}');
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur récupération recharges: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Récupérer les recharges par statut
|
|
static Future<List<Map<String, dynamic>>> getRechargesByStatus(
|
|
String status,
|
|
) async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('$baseUrl/recharge/get_recharges?status=$status'),
|
|
headers: apiHeaders,
|
|
);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
final allRecharges = List<Map<String, dynamic>>.from(
|
|
data['all_recharges'] ?? [],
|
|
);
|
|
return allRecharges.where((r) => r['status'] == status).toList();
|
|
} else {
|
|
throw Exception('Erreur HTTP ${response.statusCode}');
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur récupération recharges par statut: $e');
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
/// Approuver une recharge
|
|
static Future<Map<String, dynamic>> approveRecharge(
|
|
String rechargeId, {
|
|
required String approvedBy,
|
|
}) async {
|
|
try {
|
|
final response = await http.put(
|
|
Uri.parse('$baseUrl/recharge/approve/$rechargeId'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'status': 'approved',
|
|
'approved_by': approvedBy,
|
|
'approved_at': DateTime.now().toIso8601String(),
|
|
}),
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
if (response.statusCode == 200) {
|
|
return {
|
|
'success': data['success'] ?? false,
|
|
'message': data['message'] ?? 'Recharge approuvée avec succès',
|
|
'data': data['data'],
|
|
};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'message': data['message'] ?? 'Erreur lors de l\'approbation',
|
|
'error_code': response.statusCode,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur approbation recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
|
|
/// Rejeter une recharge
|
|
static Future<Map<String, dynamic>> rejectRecharge(
|
|
String rechargeId, {
|
|
required String rejectedBy,
|
|
String? reason,
|
|
}) async {
|
|
try {
|
|
final response = await http.put(
|
|
Uri.parse('$baseUrl/recharge/reject/$rechargeId'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'status': 'rejected',
|
|
'rejected_by': rejectedBy,
|
|
'rejected_at': DateTime.now().toIso8601String(),
|
|
'rejection_reason': reason ?? 'Demande rejetée par le rechargeur',
|
|
}),
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
if (response.statusCode == 200) {
|
|
return {
|
|
'success': data['success'] ?? false,
|
|
'message': data['message'] ?? 'Recharge rejetée avec succès',
|
|
'data': data['data'],
|
|
};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'message': data['message'] ?? 'Erreur lors du rejet',
|
|
'error_code': response.statusCode,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur rejet recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
|
|
/// Traiter une nouvelle recharge directe
|
|
static Future<Map<String, dynamic>> processDirectRecharge(
|
|
String agentId,
|
|
double amount, {
|
|
required String processedBy,
|
|
}) async {
|
|
try {
|
|
final response = await http.post(
|
|
Uri.parse('$baseUrl/recharge/process'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'agent_id': agentId,
|
|
'montant': amount,
|
|
'rechargeur_id': processedBy,
|
|
'processed_at': DateTime.now().toIso8601String(),
|
|
'status': 'completed',
|
|
'type': 'direct',
|
|
}),
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
if (response.statusCode == 200) {
|
|
return {
|
|
'success': data['success'] ?? false,
|
|
'message': data['message'] ?? 'Recharge effectuée avec succès',
|
|
'data': data['data'],
|
|
'transaction_id': data['transaction_id'],
|
|
};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'message': data['message'] ?? 'Erreur lors de la recharge',
|
|
'error_code': response.statusCode,
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur traitement recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
|
|
/// Récupérer les statistiques de recharge
|
|
static Future<Map<String, dynamic>> getRechargeStats({
|
|
String? rechargeurId,
|
|
DateTime? startDate,
|
|
DateTime? endDate,
|
|
}) async {
|
|
try {
|
|
final queryParams = <String, String>{};
|
|
|
|
if (rechargeurId != null) queryParams['rechargeur_id'] = rechargeurId;
|
|
if (startDate != null) {
|
|
queryParams['start_date'] = startDate.toIso8601String();
|
|
}
|
|
if (endDate != null) queryParams['end_date'] = endDate.toIso8601String();
|
|
|
|
final uri = Uri.parse(
|
|
'$baseUrl/recharge/stats',
|
|
).replace(queryParameters: queryParams);
|
|
|
|
final response = await http.get(uri, headers: apiHeaders);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
return {'success': true, 'stats': data['stats'] ?? {}};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'message': 'Erreur lors de la récupération des statistiques',
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur récupération stats recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
|
|
/// Récupérer l'historique des recharges avec pagination
|
|
static Future<Map<String, dynamic>> getRechargeHistory({
|
|
String? rechargeurId,
|
|
String? agentId,
|
|
String? status,
|
|
int page = 1,
|
|
int limit = 20,
|
|
}) async {
|
|
try {
|
|
final queryParams = <String, String>{
|
|
'page': page.toString(),
|
|
'limit': limit.toString(),
|
|
};
|
|
|
|
if (rechargeurId != null) queryParams['rechargeur_id'] = rechargeurId;
|
|
if (agentId != null) queryParams['agent_id'] = agentId;
|
|
if (status != null) queryParams['status'] = status;
|
|
|
|
final uri = Uri.parse(
|
|
'$baseUrl/recharge/history',
|
|
).replace(queryParameters: queryParams);
|
|
|
|
final response = await http.get(uri, headers: apiHeaders);
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = jsonDecode(response.body);
|
|
return {
|
|
'success': true,
|
|
'recharges': data['recharges'] ?? [],
|
|
'pagination': data['pagination'] ?? {},
|
|
'total': data['total'] ?? 0,
|
|
};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'message': 'Erreur lors de la récupération de l\'historique',
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur récupération historique recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
|
|
/// Valider qu'un agent existe avant recharge
|
|
static Future<Map<String, dynamic>> validateAgent(String agentId) async {
|
|
try {
|
|
final response = await http.get(
|
|
Uri.parse('$baseUrl/agent/$agentId/validate'),
|
|
headers: apiHeaders,
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
if (response.statusCode == 200) {
|
|
return {
|
|
'success': data['success'] ?? false,
|
|
'exists': data['exists'] ?? false,
|
|
'agent_name': data['agent_name'],
|
|
'agent_status': data['agent_status'],
|
|
'current_balance': data['current_balance'],
|
|
};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'exists': false,
|
|
'message': data['message'] ?? 'Agent introuvable',
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur validation agent: $e');
|
|
return {
|
|
'success': false,
|
|
'exists': false,
|
|
'message': 'Erreur de connexion: $e',
|
|
};
|
|
}
|
|
}
|
|
|
|
/// Annuler une recharge en attente
|
|
static Future<Map<String, dynamic>> cancelPendingRecharge(
|
|
String rechargeId, {
|
|
required String cancelledBy,
|
|
String? reason,
|
|
}) async {
|
|
try {
|
|
final response = await http.put(
|
|
Uri.parse('$baseUrl/recharge/cancel/$rechargeId'),
|
|
headers: apiHeaders,
|
|
body: jsonEncode({
|
|
'status': 'cancelled',
|
|
'cancelled_by': cancelledBy,
|
|
'cancelled_at': DateTime.now().toIso8601String(),
|
|
'cancellation_reason': reason ?? 'Annulée par le rechargeur',
|
|
}),
|
|
);
|
|
|
|
final data = jsonDecode(response.body);
|
|
|
|
if (response.statusCode == 200) {
|
|
return {
|
|
'success': data['success'] ?? false,
|
|
'message': data['message'] ?? 'Recharge annulée avec succès',
|
|
};
|
|
} else {
|
|
return {
|
|
'success': false,
|
|
'message': data['message'] ?? 'Erreur lors de l\'annulation',
|
|
};
|
|
}
|
|
} catch (e) {
|
|
print('❌ Erreur annulation recharge: $e');
|
|
return {'success': false, 'message': 'Erreur de connexion: $e'};
|
|
}
|
|
}
|
|
}
|
|
|
|
// ===== MODÈLES DE DONNÉES =====
|
|
|
|
/// Modèle pour une recharge
|
|
class RechargeModel {
|
|
final String id;
|
|
final String agentId;
|
|
final double montant;
|
|
final String status;
|
|
final DateTime? createdAt;
|
|
final DateTime? processedAt;
|
|
final String? processedBy;
|
|
final String? rejectionReason;
|
|
|
|
RechargeModel({
|
|
required this.id,
|
|
required this.agentId,
|
|
required this.montant,
|
|
required this.status,
|
|
this.createdAt,
|
|
this.processedAt,
|
|
this.processedBy,
|
|
this.rejectionReason,
|
|
});
|
|
|
|
factory RechargeModel.fromJson(Map<String, dynamic> json) {
|
|
return RechargeModel(
|
|
id: json['_id'] is Map ? json['_id']['\$oid'] : json['_id'].toString(),
|
|
agentId: json['agent_id'] ?? '',
|
|
montant: double.tryParse(json['montant'].toString()) ?? 0.0,
|
|
status: json['status'] ?? 'pending',
|
|
createdAt:
|
|
json['created_at'] != null
|
|
? DateTime.tryParse(json['created_at'])
|
|
: null,
|
|
processedAt:
|
|
json['processed_at'] != null
|
|
? DateTime.tryParse(json['processed_at'])
|
|
: null,
|
|
processedBy: json['processed_by'],
|
|
rejectionReason: json['rejection_reason'],
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'id': id,
|
|
'agent_id': agentId,
|
|
'montant': montant,
|
|
'status': status,
|
|
'created_at': createdAt?.toIso8601String(),
|
|
'processed_at': processedAt?.toIso8601String(),
|
|
'processed_by': processedBy,
|
|
'rejection_reason': rejectionReason,
|
|
};
|
|
}
|
|
|
|
bool get isPending => status == 'pending';
|
|
bool get isApproved => status == 'approved';
|
|
bool get isRejected => status == 'rejected';
|
|
bool get isCancelled => status == 'cancelled';
|
|
bool get isCompleted => status == 'completed';
|
|
|
|
String get statusDisplayName {
|
|
switch (status) {
|
|
case 'pending':
|
|
return 'En attente';
|
|
case 'approved':
|
|
return 'Approuvée';
|
|
case 'rejected':
|
|
return 'Rejetée';
|
|
case 'cancelled':
|
|
return 'Annulée';
|
|
case 'completed':
|
|
return 'Terminée';
|
|
default:
|
|
return status.toUpperCase();
|
|
}
|
|
}
|
|
|
|
Color get statusColor {
|
|
switch (status) {
|
|
case 'pending':
|
|
return RechargeurColors.warning;
|
|
case 'approved':
|
|
case 'completed':
|
|
return RechargeurColors.success;
|
|
case 'rejected':
|
|
case 'cancelled':
|
|
return RechargeurColors.error;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Modèle pour les statistiques de recharge
|
|
class RechargeStatsModel {
|
|
final int totalRecharges;
|
|
final double totalAmount;
|
|
final int pendingCount;
|
|
final int approvedCount;
|
|
final int rejectedCount;
|
|
final double averageAmount;
|
|
final Map<String, int> rechargesByDay;
|
|
|
|
RechargeStatsModel({
|
|
required this.totalRecharges,
|
|
required this.totalAmount,
|
|
required this.pendingCount,
|
|
required this.approvedCount,
|
|
required this.rejectedCount,
|
|
required this.averageAmount,
|
|
required this.rechargesByDay,
|
|
});
|
|
|
|
factory RechargeStatsModel.fromJson(Map<String, dynamic> json) {
|
|
return RechargeStatsModel(
|
|
totalRecharges: json['total_recharges'] ?? 0,
|
|
totalAmount: (json['total_amount'] ?? 0.0).toDouble(),
|
|
pendingCount: json['pending_count'] ?? 0,
|
|
approvedCount: json['approved_count'] ?? 0,
|
|
rejectedCount: json['rejected_count'] ?? 0,
|
|
averageAmount: (json['average_amount'] ?? 0.0).toDouble(),
|
|
rechargesByDay: Map<String, int>.from(json['recharges_by_day'] ?? {}),
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'total_recharges': totalRecharges,
|
|
'total_amount': totalAmount,
|
|
'pending_count': pendingCount,
|
|
'approved_count': approvedCount,
|
|
'rejected_count': rejectedCount,
|
|
'average_amount': averageAmount,
|
|
'recharges_by_day': rechargesByDay,
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===== UTILITAIRES =====
|
|
|
|
/// Utilitaires pour les recharges
|
|
class RechargeUtils {
|
|
/// Formater un montant en FCFA
|
|
static String formatAmount(double amount) {
|
|
return '${amount.toStringAsFixed(0).replaceAllMapped(RegExp(r'(\d{1,3})(?=(\d{3})+(?!\d))'), (Match m) => '${m[1]} ')} FCFA';
|
|
}
|
|
|
|
/// Valider un ID d'agent
|
|
static bool isValidAgentId(String agentId) {
|
|
return agentId.isNotEmpty &&
|
|
agentId.length >= 3 &&
|
|
agentId.length <= 10 &&
|
|
RegExp(r'^[A-Z0-9]+$').hasMatch(agentId);
|
|
}
|
|
|
|
/// Valider un montant de recharge
|
|
static bool isValidAmount(double amount) {
|
|
return amount > 0 && amount <= 10000000; // Max 10M FCFA
|
|
}
|
|
|
|
/// Obtenir la couleur selon le statut
|
|
static Color getStatusColor(String status) {
|
|
switch (status.toLowerCase()) {
|
|
case 'pending':
|
|
return RechargeurColors.warning;
|
|
case 'approved':
|
|
case 'completed':
|
|
return RechargeurColors.success;
|
|
case 'rejected':
|
|
case 'cancelled':
|
|
return RechargeurColors.error;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
/// Obtenir l'icône selon le statut
|
|
static IconData getStatusIcon(String status) {
|
|
switch (status.toLowerCase()) {
|
|
case 'pending':
|
|
return Icons.pending;
|
|
case 'approved':
|
|
case 'completed':
|
|
return Icons.check_circle;
|
|
case 'rejected':
|
|
case 'cancelled':
|
|
return Icons.cancel;
|
|
default:
|
|
return Icons.help_outline;
|
|
}
|
|
}
|
|
|
|
/// Formater une date relative
|
|
static String formatRelativeDate(DateTime date) {
|
|
final now = DateTime.now();
|
|
final difference = now.difference(date);
|
|
|
|
if (difference.inDays > 0) {
|
|
return '${difference.inDays}j';
|
|
} else if (difference.inHours > 0) {
|
|
return '${difference.inHours}h';
|
|
} else if (difference.inMinutes > 0) {
|
|
return '${difference.inMinutes}min';
|
|
} else {
|
|
return 'Maintenant';
|
|
}
|
|
}
|
|
}
|