You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
2524 lines
96 KiB
2524 lines
96 KiB
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:fl_chart/fl_chart.dart';
|
|
import 'package:youmazgestion/Components/appDrawer.dart';
|
|
import 'package:youmazgestion/Services/stock_managementDatabase.dart';
|
|
import 'package:youmazgestion/controller/userController.dart';
|
|
import 'package:youmazgestion/Models/users.dart';
|
|
import 'package:youmazgestion/Models/client.dart';
|
|
import 'package:youmazgestion/Models/produit.dart'; // Ajout de l'import manquant
|
|
import 'package:intl/intl.dart';
|
|
|
|
class DashboardPage extends StatefulWidget {
|
|
@override
|
|
_DashboardPageState createState() => _DashboardPageState();
|
|
}
|
|
|
|
class _DashboardPageState extends State<DashboardPage> with SingleTickerProviderStateMixin {
|
|
DateTimeRange? _dateRange;
|
|
bool _showOnlyToday = false;
|
|
final AppDatabase _database = AppDatabase.instance;
|
|
final UserController _userController = Get.find<UserController>();
|
|
final GlobalKey _recentClientsKey = GlobalKey();
|
|
final GlobalKey _recentOrdersKey = GlobalKey();
|
|
final GlobalKey _lowStockKey = GlobalKey();
|
|
final GlobalKey _salesChartKey = GlobalKey();
|
|
late Future<Map<String, dynamic>> _statsFuture;
|
|
late Future<List<Commande>> _recentOrdersFuture;
|
|
late Future<List<Product>> _lowStockProductsFuture;
|
|
late Future<List<Client>> _recentClientsFuture;
|
|
late Future<List<Commande>> _allOrdersFuture;
|
|
late Future<Map<String, int>> _productsByCategoryFuture;
|
|
|
|
late AnimationController _animationController;
|
|
late Animation<double> _fadeAnimation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadData();
|
|
|
|
_animationController = AnimationController(
|
|
vsync: this,
|
|
duration: Duration(milliseconds: 800),
|
|
);
|
|
_fadeAnimation = Tween<double>(begin: 0, end: 1).animate(
|
|
CurvedAnimation(
|
|
parent: _animationController,
|
|
curve: Curves.easeInOut,
|
|
),
|
|
);
|
|
|
|
// Démarrer l'animation après un léger délai
|
|
Future.delayed(Duration(milliseconds: 50), () {
|
|
if (mounted) {
|
|
_animationController.forward();
|
|
}
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_animationController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _loadData() {
|
|
_statsFuture = _database.getStatistiques(); // Plus besoin de calcul supplémentaire
|
|
_recentOrdersFuture = _database.getCommandes().then((orders) => orders.take(5).toList());
|
|
_lowStockProductsFuture = _database.getProducts().then((products) {
|
|
return products.where((p) => (p.stock ?? 0) < 10).toList();
|
|
});
|
|
_recentClientsFuture = _database.getClients().then((clients) => clients.take(5).toList());
|
|
_allOrdersFuture = _database.getCommandes();
|
|
_productsByCategoryFuture = _database.getProductCountByCategory();
|
|
}
|
|
Future<void> _showCategoryProductsDialog(String category) async {
|
|
final products = await _database.getProductsByCategory(category);
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: Text('Produits dans $category'),
|
|
content: Container(
|
|
width: double.maxFinite,
|
|
child: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: products.length,
|
|
itemBuilder: (context, index) {
|
|
final product = products[index];
|
|
return ListTile(
|
|
leading: product.image != null && product.image!.isNotEmpty
|
|
? CircleAvatar(backgroundImage: NetworkImage(product.image!))
|
|
: CircleAvatar(child: Icon(Icons.inventory)),
|
|
title: Text(product.name),
|
|
subtitle: Text('Stock: ${product.stock}'),
|
|
trailing: Text('${NumberFormat('#,##0', 'fr_FR').format(product.price)} MGA'),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
List<Map<String, dynamic>> _groupOrdersByMonth(List<Commande> orders) {
|
|
final Map<String, double> monthlySales = {};
|
|
|
|
for (final order in orders) {
|
|
final monthYear = '${order.dateCommande.year}-${order.dateCommande.month.toString().padLeft(2, '0')}';
|
|
monthlySales.update(
|
|
monthYear,
|
|
(value) => value + order.montantTotal,
|
|
ifAbsent: () => order.montantTotal,
|
|
);
|
|
}
|
|
|
|
return monthlySales.entries.map((entry) {
|
|
return {
|
|
'month': entry.key,
|
|
'total': entry.value,
|
|
};
|
|
}).toList();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Image.asset(
|
|
'assets/youmaz2.png',
|
|
height: 40, // Ajustez la hauteur selon vos besoins
|
|
),
|
|
centerTitle: true,
|
|
elevation: 0,
|
|
flexibleSpace: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [const Color.fromARGB(255, 15, 83, 160), const Color.fromARGB(255, 79, 165, 239)],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
IconButton(
|
|
icon: Icon(Icons.refresh, color: Colors.white),
|
|
onPressed: () {
|
|
_animationController.reset();
|
|
_loadData();
|
|
_animationController.forward();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
drawer: CustomDrawer(),
|
|
body: SingleChildScrollView(
|
|
padding: EdgeInsets.all(16),
|
|
child: FadeTransition(
|
|
opacity: _fadeAnimation,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_buildUserInfo(),
|
|
SizedBox(height: 20),
|
|
|
|
_buildMiniStatistics(),
|
|
SizedBox(height: 20),
|
|
|
|
// Graphiques en ligne
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _buildSalesChart(),
|
|
),
|
|
SizedBox(width: 16),
|
|
Expanded(
|
|
child: _buildStockChart(),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 20),
|
|
|
|
// Histogramme des catégories de produits
|
|
_buildCategoryHistogram(),
|
|
SizedBox(height: 20),
|
|
// NOUVEAU: Widget des ventes par point de vente
|
|
_buildVentesParPointDeVenteCard(),
|
|
SizedBox(height: 20),
|
|
// Section des données récentes
|
|
_buildRecentDataSection(),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildCategoryHistogram() {
|
|
return Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.category, color: Colors.blue),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Produits par Catégorie',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 16),
|
|
Container(
|
|
height: 200,
|
|
child: FutureBuilder<Map<String, int>>(
|
|
future: _productsByCategoryFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
|
|
return Center(child: Text('Aucune donnée disponible'));
|
|
}
|
|
|
|
final data = snapshot.data!;
|
|
final categories = data.keys.toList();
|
|
final counts = data.values.toList();
|
|
|
|
return BarChart(
|
|
BarChartData(
|
|
alignment: BarChartAlignment.spaceAround,
|
|
maxY: counts.reduce((a, b) => a > b ? a : b).toDouble() * 1.2,
|
|
barTouchData: BarTouchData(
|
|
enabled: true,
|
|
touchCallback: (FlTouchEvent event, response) {
|
|
if (response != null && response.spot != null && event is FlTapUpEvent) {
|
|
final category = categories[response.spot!.touchedBarGroupIndex];
|
|
_showCategoryProductsDialog(category);
|
|
}
|
|
},
|
|
touchTooltipData: BarTouchTooltipData(
|
|
tooltipBgColor: Colors.blueGrey,
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
final category = categories[groupIndex];
|
|
final count = counts[groupIndex];
|
|
return BarTooltipItem(
|
|
'$category\n$count produits',
|
|
TextStyle(color: Colors.white),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
final index = value.toInt();
|
|
if (index >= 0 && index < categories.length) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
categories[index].substring(0, 3).toUpperCase(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Text('');
|
|
},
|
|
reservedSize: 40,
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
return Text(
|
|
value.toInt().toString(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey,
|
|
),
|
|
);
|
|
},
|
|
reservedSize: 40,
|
|
),
|
|
),
|
|
topTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
rightTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
),
|
|
borderData: FlBorderData(
|
|
show: true,
|
|
border: Border.all(
|
|
color: Colors.grey.withOpacity(0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
barGroups: categories.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
return BarChartGroupData(
|
|
x: index,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: counts[index].toDouble(),
|
|
color: _getCategoryColor(index),
|
|
width: 16,
|
|
borderRadius: BorderRadius.circular(4),
|
|
backDrawRodData: BackgroundBarChartRodData(
|
|
show: true,
|
|
toY: counts.reduce((a, b) => a > b ? a : b).toDouble() * 1.2,
|
|
color: Colors.grey.withOpacity(0.1),
|
|
),
|
|
),
|
|
],
|
|
showingTooltipIndicators: [0],
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getCategoryColor(int index) {
|
|
final colors = [
|
|
Colors.blue,
|
|
Colors.green,
|
|
Colors.orange,
|
|
Colors.purple,
|
|
Colors.teal,
|
|
Colors.pink,
|
|
Colors.indigo,
|
|
];
|
|
return colors[index % colors.length];
|
|
}
|
|
|
|
Widget _buildSalesChart() {
|
|
|
|
return Card(
|
|
key: _salesChartKey,
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// ... titre
|
|
Container(
|
|
height: 200,
|
|
child: FutureBuilder<List<Commande>>(
|
|
future: _allOrdersFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.trending_up_outlined, size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text('Aucune donnée de vente disponible', style: TextStyle(color: Colors.grey)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final salesData = _groupOrdersByMonth(snapshot.data!);
|
|
|
|
// Vérification si salesData est vide
|
|
if (salesData.isEmpty) {
|
|
return Center(
|
|
child: Text('Aucune donnée de vente disponible', style: TextStyle(color: Colors.grey)),
|
|
);
|
|
}
|
|
|
|
return BarChart(
|
|
BarChartData(
|
|
alignment: BarChartAlignment.spaceAround,
|
|
maxY: salesData.map((e) => e['total']).reduce((a, b) => a > b ? a : b) * 1.2,
|
|
barTouchData: BarTouchData(
|
|
enabled: true,
|
|
touchTooltipData: BarTouchTooltipData(
|
|
tooltipBgColor: Colors.blueGrey,
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
final month = salesData[groupIndex]['month'];
|
|
final total = salesData[groupIndex]['total'];
|
|
return BarTooltipItem(
|
|
'$month\n${NumberFormat('#,##0', 'fr_FR').format(total)} MGA',
|
|
TextStyle(color: Colors.white),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
final index = value.toInt();
|
|
if (index >= 0 && index < salesData.length) {
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
salesData[index]['month'].toString().split('-')[1],
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Text('');
|
|
},
|
|
reservedSize: 40,
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
return Text(
|
|
value.toInt().toString(),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey,
|
|
),
|
|
);
|
|
},
|
|
reservedSize: 40,
|
|
),
|
|
),
|
|
topTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
rightTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
),
|
|
borderData: FlBorderData(
|
|
show: true,
|
|
border: Border.all(
|
|
color: Colors.grey.withOpacity(0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
barGroups: salesData.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final data = entry.value;
|
|
return BarChartGroupData(
|
|
x: index,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: data['total'],
|
|
color: Colors.blue,
|
|
width: 16,
|
|
borderRadius: BorderRadius.circular(4),
|
|
backDrawRodData: BackgroundBarChartRodData(
|
|
show: true,
|
|
toY: salesData.map((e) => e['total']).reduce((a, b) => a > b ? a : b) * 1.2,
|
|
color: Colors.grey.withOpacity(0.1),
|
|
),
|
|
),
|
|
],
|
|
showingTooltipIndicators: [0],
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStockChart() {
|
|
return Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.inventory, color: Colors.blue),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'État du stock',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 16),
|
|
Container(
|
|
height: 200,
|
|
child: FutureBuilder<List<Product>>(
|
|
future: _database.getProducts(),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData) {
|
|
return Center(child: Text('Aucune donnée disponible'));
|
|
}
|
|
|
|
final products = snapshot.data!;
|
|
|
|
// Vérification si la liste est vide
|
|
if (products.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text('Aucun produit en stock', style: TextStyle(color: Colors.grey)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final lowStock = products.where((p) => (p.stock ?? 0) < 10).length;
|
|
final inStock = products.length - lowStock;
|
|
|
|
// Vérification pour éviter les sections vides
|
|
List<PieChartSectionData> sections = [];
|
|
|
|
if (lowStock > 0) {
|
|
sections.add(
|
|
PieChartSectionData(
|
|
color: Colors.orange,
|
|
value: lowStock.toDouble(),
|
|
title: '$lowStock',
|
|
radius: 20,
|
|
titleStyle: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (inStock > 0) {
|
|
sections.add(
|
|
PieChartSectionData(
|
|
color: Colors.green,
|
|
value: inStock.toDouble(),
|
|
title: '$inStock',
|
|
radius: 20,
|
|
titleStyle: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Si toutes les sections sont vides, afficher un message
|
|
if (sections.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.info_outline, size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text('Aucune donnée de stock disponible', style: TextStyle(color: Colors.grey)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
return PieChart(
|
|
PieChartData(
|
|
sectionsSpace: 0,
|
|
centerSpaceRadius: 40,
|
|
sections: sections,
|
|
pieTouchData: PieTouchData(
|
|
enabled: true, // Activé pour permettre les interactions
|
|
touchCallback: (FlTouchEvent event, pieTouchResponse) {
|
|
// Gestion sécurisée des interactions
|
|
if (pieTouchResponse != null &&
|
|
pieTouchResponse.touchedSection != null) {
|
|
// Vous pouvez ajouter une logique ici si nécessaire
|
|
}
|
|
},
|
|
),
|
|
startDegreeOffset: 180,
|
|
borderData: FlBorderData(show: false),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
SizedBox(height: 8),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
_buildLegendItem(Colors.orange, 'Stock faible'),
|
|
SizedBox(width: 16),
|
|
_buildLegendItem(Colors.green, 'En stock'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildLegendItem(Color color, String text) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: color,
|
|
),
|
|
),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
text,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildUserInfo() {
|
|
return FutureBuilder<Users?>(
|
|
future: _database.getUserById(_userController.userId),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData) {
|
|
return Text('Bienvenue');
|
|
}
|
|
|
|
final user = snapshot.data!;
|
|
return Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 30,
|
|
backgroundColor: Colors.blue.shade100,
|
|
child: Icon(Icons.person, size: 30, color: Colors.blue),
|
|
),
|
|
SizedBox(width: 16),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Bienvenue, ${user.name}',
|
|
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
'Rôle: ${user.roleName ?? 'Utilisateur'}',
|
|
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildMiniStatistics() {
|
|
return FutureBuilder<Map<String, dynamic>>(
|
|
future: _statsFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return Text('Erreur de chargement des statistiques');
|
|
}
|
|
|
|
final stats = snapshot.data ?? {};
|
|
|
|
return Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
_buildMiniStatCard(
|
|
title: 'Clients',
|
|
value: '${stats['totalClients'] ?? 0}',
|
|
icon: Icons.people,
|
|
color: Colors.blue,
|
|
),
|
|
_buildMiniStatCard(
|
|
title: 'Commandes',
|
|
value: '${stats['totalCommandes'] ?? 0}',
|
|
icon: Icons.shopping_cart,
|
|
color: Colors.green,
|
|
),
|
|
_buildMiniStatCard(
|
|
title: 'Produits',
|
|
value: '${stats['totalProduits'] ?? 0}',
|
|
icon: Icons.inventory,
|
|
color: Colors.orange,
|
|
),
|
|
_buildMiniStatCard(
|
|
title: 'CA (MGA)',
|
|
value: NumberFormat('#,##0', 'fr_FR').format(stats['chiffreAffaires'] ?? 0.0),
|
|
icon: Icons.euro_symbol,
|
|
color: Colors.purple,
|
|
),
|
|
// ✅ NOUVELLE CARTE : Valeur totale du stock
|
|
_buildMiniStatCard(
|
|
title: 'Valeur Stock (MGA)',
|
|
value: NumberFormat('#,##0', 'fr_FR').format(stats['valeurTotaleStock'] ?? 0.0),
|
|
icon: Icons.inventory_2,
|
|
color: Colors.teal,
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
|
|
Widget _buildMiniStatCard({required String title, required String value, required IconData icon, required Color color}) {
|
|
|
|
return InkWell(
|
|
onTap: () {
|
|
// Animation au clic
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
|
|
// Navigation based on the card type
|
|
switch(title) {
|
|
case 'Clients':
|
|
// Scroll to recent clients section
|
|
Scrollable.ensureVisible(
|
|
_recentClientsKey.currentContext!,
|
|
duration: Duration(milliseconds: 500),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
break;
|
|
case 'Commandes':
|
|
// Scroll to recent orders section
|
|
Scrollable.ensureVisible(
|
|
_recentOrdersKey.currentContext!,
|
|
duration: Duration(milliseconds: 500),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
break;
|
|
case 'Produits':
|
|
// Scroll to low stock products section
|
|
Scrollable.ensureVisible(
|
|
_lowStockKey.currentContext!,
|
|
duration: Duration(milliseconds: 500),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
break;
|
|
case 'CA (MGA)':
|
|
// Scroll to sales chart
|
|
Scrollable.ensureVisible(
|
|
_salesChartKey.currentContext!,
|
|
duration: Duration(milliseconds: 500),
|
|
curve: Curves.easeInOut,
|
|
);
|
|
break;
|
|
}
|
|
},
|
|
borderRadius: BorderRadius.circular(12),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: color.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: color.withOpacity(0.3)),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(12),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, size: 32, color: color),
|
|
SizedBox(height: 8),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.bold,
|
|
color: color,
|
|
),
|
|
),
|
|
SizedBox(height: 4),
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
Widget _buildRecentDataSection() {
|
|
return Column(
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(child: _buildRecentOrdersCard()),
|
|
SizedBox(width: 16),
|
|
Expanded(child: _buildRecentClientsCard()),
|
|
],
|
|
),
|
|
SizedBox(height: 16),
|
|
_buildLowStockCard(),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildRecentOrdersCard() {
|
|
|
|
return Card(
|
|
key: _recentOrdersKey,
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.shopping_cart, color: Colors.green),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Commandes récentes',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 8),
|
|
FutureBuilder<List<Commande>>(
|
|
future: _recentOrdersFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
|
|
return Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: Text('Aucune commande récente'),
|
|
);
|
|
}
|
|
|
|
final orders = snapshot.data!;
|
|
|
|
return Column(
|
|
children: orders.map((order) => FutureBuilder<List<DetailCommande>>(
|
|
future: _database.getDetailsCommande(order.id!),
|
|
builder: (context, detailsSnapshot) {
|
|
if (detailsSnapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (detailsSnapshot.hasError || !detailsSnapshot.hasData || detailsSnapshot.data!.isEmpty) {
|
|
return Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: Text('Aucun détail de commande disponible'),
|
|
);
|
|
}
|
|
|
|
final details = detailsSnapshot.data!;
|
|
|
|
return InkWell(
|
|
onTap: () {
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
},
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
margin: EdgeInsets.only(bottom: 8),
|
|
padding: EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: CircleAvatar(
|
|
backgroundColor: _getStatusColor(order.statut).withOpacity(0.2),
|
|
child: Icon(Icons.receipt, color: _getStatusColor(order.statut)),
|
|
),
|
|
title: Text(
|
|
'${order.clientNomComplet}',
|
|
style: TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${NumberFormat('#,##0', 'fr_FR').format(order.montantTotal)} MGA',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
Text(
|
|
'${order.dateCommande.toString().substring(0, 10)}',
|
|
style: TextStyle(fontSize: 10, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
trailing: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: _getStatusColor(order.statut).withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
order.statutLibelle,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: _getStatusColor(order.statut),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
// Affichage des produits commandés
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: details.map((detail) => Padding(
|
|
padding: EdgeInsets.only(left: 16, top: 4),
|
|
child: Text(
|
|
'Produit: ${detail.produitNom}',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
)).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
)).toList(),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
Widget _buildRecentClientsCard() {
|
|
|
|
return Card(
|
|
key: _recentClientsKey,
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.people, color: Colors.blue),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Clients récents',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 8),
|
|
FutureBuilder<List<Client>>(
|
|
future: _recentClientsFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
|
|
return Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: Text('Aucun client récent'),
|
|
);
|
|
}
|
|
|
|
final clients = snapshot.data!;
|
|
|
|
return Column(
|
|
children: clients.map((client) => InkWell(
|
|
onTap: () {
|
|
// Animation et action au clic
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
// Vous pouvez ajouter une navigation vers le client ici
|
|
},
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
margin: EdgeInsets.only(bottom: 8),
|
|
padding: EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: CircleAvatar(
|
|
backgroundColor: Colors.blue.shade100,
|
|
child: Icon(Icons.person, color: Colors.blue),
|
|
),
|
|
title: Text(
|
|
client.nomComplet.split(' ').first,
|
|
style: TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Text(
|
|
client.email,
|
|
style: TextStyle(fontSize: 12),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
),
|
|
)).toList(),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
//widget vente
|
|
// 2. Ajoutez cette méthode dans la classe _DashboardPageState
|
|
Widget _buildVentesParPointDeVenteCard() {
|
|
return Card(
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.store, color: Colors.purple),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Ventes par Point de Vente',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Spacer(),
|
|
// Boutons de filtre dans le header
|
|
Row(
|
|
children: [
|
|
IconButton(
|
|
onPressed: _toggleTodayFilter,
|
|
icon: Icon(
|
|
_showOnlyToday ? Icons.today : Icons.calendar_today,
|
|
color: _showOnlyToday ? Colors.green : Colors.grey,
|
|
),
|
|
tooltip: _showOnlyToday ? 'Toutes les dates' : 'Aujourd\'hui seulement',
|
|
),
|
|
IconButton(
|
|
onPressed: () => _selectDateRange(context),
|
|
icon: Icon(
|
|
Icons.date_range,
|
|
color: _dateRange != null ? Colors.orange : Colors.grey,
|
|
),
|
|
tooltip: 'Sélectionner une période',
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
// Affichage de la période sélectionnée
|
|
if (_showOnlyToday || _dateRange != null)
|
|
Padding(
|
|
padding: EdgeInsets.only(bottom: 8),
|
|
child: Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: (_showOnlyToday ? Colors.green : Colors.orange).withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: (_showOnlyToday ? Colors.green : Colors.orange).withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_showOnlyToday ? Icons.today : Icons.date_range,
|
|
size: 16,
|
|
color: _showOnlyToday ? Colors.green : Colors.orange,
|
|
),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
_showOnlyToday
|
|
? 'Aujourd\'hui'
|
|
: _dateRange != null
|
|
? '${DateFormat('dd/MM/yyyy').format(_dateRange!.start)} - ${DateFormat('dd/MM/yyyy').format(_dateRange!.end)}'
|
|
: 'Toutes les dates',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: _showOnlyToday ? Colors.green : Colors.orange,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
SizedBox(width: 4),
|
|
InkWell(
|
|
onTap: () {
|
|
setState(() {
|
|
_showOnlyToday = false;
|
|
_dateRange = null;
|
|
});
|
|
},
|
|
child: Icon(
|
|
Icons.close,
|
|
size: 16,
|
|
color: _showOnlyToday ? Colors.green : Colors.orange,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
SizedBox(height: 16),
|
|
Container(
|
|
height: 400,
|
|
child: FutureBuilder<List<Map<String, dynamic>>>(
|
|
future: _database.getVentesParPointDeVente(
|
|
dateDebut: _dateRange?.start,
|
|
dateFin: _dateRange?.end,
|
|
aujourdHuiSeulement: _showOnlyToday,
|
|
),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(Icons.store_mall_directory_outlined, size: 64, color: Colors.grey),
|
|
SizedBox(height: 16),
|
|
Text(
|
|
'Aucune donnée de vente${_showOnlyToday ? ' pour aujourd\'hui' : _dateRange != null ? ' pour cette période' : ''}',
|
|
style: TextStyle(color: Colors.grey),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
final ventesData = snapshot.data!;
|
|
|
|
return SingleChildScrollView(
|
|
child: Column(
|
|
children: [
|
|
// Graphique en barres des chiffres d'affaires
|
|
Container(
|
|
height: 200,
|
|
child: BarChart(
|
|
BarChartData(
|
|
alignment: BarChartAlignment.spaceAround,
|
|
maxY: _getMaxChiffreAffaires(ventesData) * 1.2,
|
|
barTouchData: BarTouchData(
|
|
enabled: true,
|
|
touchTooltipData: BarTouchTooltipData(
|
|
tooltipBgColor: Colors.blueGrey,
|
|
getTooltipItem: (group, groupIndex, rod, rodIndex) {
|
|
final pointVente = ventesData[groupIndex];
|
|
final ca = pointVente['chiffre_affaires'] ?? 0.0;
|
|
final nbCommandes = pointVente['nombre_commandes'] ?? 0;
|
|
return BarTooltipItem(
|
|
'${pointVente['point_vente_nom']}\n${NumberFormat('#,##0', 'fr_FR').format(ca)} MGA\n$nbCommandes commandes',
|
|
TextStyle(color: Colors.white, fontSize: 12),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
titlesData: FlTitlesData(
|
|
show: true,
|
|
bottomTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
final index = value.toInt();
|
|
if (index >= 0 && index < ventesData.length) {
|
|
final nom = ventesData[index]['point_vente_nom'] as String;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Text(
|
|
nom.length > 5 ? nom.substring(0, 5) : nom,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Text('');
|
|
},
|
|
reservedSize: 40,
|
|
),
|
|
),
|
|
leftTitles: AxisTitles(
|
|
sideTitles: SideTitles(
|
|
showTitles: true,
|
|
getTitlesWidget: (value, meta) {
|
|
return Text(
|
|
_formatCurrency(value),
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
color: Colors.grey,
|
|
),
|
|
);
|
|
},
|
|
reservedSize: 60,
|
|
),
|
|
),
|
|
topTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
rightTitles: AxisTitles(
|
|
sideTitles: SideTitles(showTitles: false),
|
|
),
|
|
),
|
|
borderData: FlBorderData(
|
|
show: true,
|
|
border: Border.all(
|
|
color: Colors.grey.withOpacity(0.3),
|
|
width: 1,
|
|
),
|
|
),
|
|
barGroups: ventesData.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final data = entry.value;
|
|
final ca = (data['chiffre_affaires'] as num?)?.toDouble() ?? 0.0;
|
|
|
|
return BarChartGroupData(
|
|
x: index,
|
|
barRods: [
|
|
BarChartRodData(
|
|
toY: ca,
|
|
color: _getPointVenteColor(index),
|
|
width: 16,
|
|
borderRadius: BorderRadius.circular(4),
|
|
backDrawRodData: BackgroundBarChartRodData(
|
|
show: true,
|
|
toY: _getMaxChiffreAffaires(ventesData) * 1.2,
|
|
color: Colors.grey.withOpacity(0.1),
|
|
),
|
|
),
|
|
],
|
|
showingTooltipIndicators: [0],
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 20),
|
|
|
|
// Tableau détaillé
|
|
_buildTableauVentesPointDeVente(ventesData),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildTableauVentesPointDeVente(List<Map<String, dynamic>> ventesData) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
// En-tête du tableau
|
|
Container(
|
|
padding: EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.withOpacity(0.1),
|
|
borderRadius: BorderRadius.only(
|
|
topLeft: Radius.circular(8),
|
|
topRight: Radius.circular(8),
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(flex: 2, child: Text('Point de Vente', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
|
|
Expanded(flex: 2, child: Text('CA (MGA)', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
|
|
Expanded(flex: 1, child: Text('Cmd', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
|
|
Expanded(flex: 1, child: Text('Articles', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
|
|
Expanded(flex: 2, child: Text('Panier Moy.', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12))),
|
|
],
|
|
),
|
|
),
|
|
|
|
// Lignes du tableau
|
|
...ventesData.asMap().entries.map((entry) {
|
|
final index = entry.key;
|
|
final data = entry.value;
|
|
final isEven = index % 2 == 0;
|
|
|
|
return InkWell(
|
|
onTap: () => _showPointVenteDetails(data),
|
|
child: Container(
|
|
padding: EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: isEven ? Colors.grey.withOpacity(0.05) : Colors.white,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 12,
|
|
height: 12,
|
|
decoration: BoxDecoration(
|
|
color: _getPointVenteColor(index),
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
data['point_vente_nom'] ?? 'N/A',
|
|
style: TextStyle(fontSize: 12),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
NumberFormat('#,##0.00', 'fr_FR').format(
|
|
(data['chiffre_affaires'] as num?)?.toDouble() ?? 0.0,
|
|
),
|
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Text(
|
|
'${data['nombre_commandes'] ?? 0}',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 1,
|
|
child: Text(
|
|
'${data['nombre_articles_vendus'] ?? 0}',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
Expanded(
|
|
flex: 2,
|
|
child: Text(
|
|
NumberFormat('#,##0.00', 'fr_FR').format(
|
|
(data['panier_moyen'] as num?)?.toDouble() ?? 0.0,
|
|
),
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Méthodes utilitaires
|
|
double _getMaxChiffreAffaires(List<Map<String, dynamic>> ventesData) {
|
|
if (ventesData.isEmpty) return 100.0;
|
|
|
|
return ventesData
|
|
.map((data) => (data['chiffre_affaires'] as num?)?.toDouble() ?? 0.0)
|
|
.reduce((a, b) => a > b ? a : b);
|
|
}
|
|
|
|
Color _getPointVenteColor(int index) {
|
|
final colors = [
|
|
Colors.blue,
|
|
Colors.green,
|
|
Colors.orange,
|
|
Colors.purple,
|
|
Colors.teal,
|
|
Colors.pink,
|
|
Colors.indigo,
|
|
Colors.amber,
|
|
Colors.cyan,
|
|
Colors.lime,
|
|
];
|
|
return colors[index % colors.length];
|
|
}
|
|
|
|
String _formatCurrency(double value) {
|
|
if (value >= 1000000) {
|
|
return '${(value / 1000000).toStringAsFixed(1)}M';
|
|
} else if (value >= 1000) {
|
|
return '${(value / 1000).toStringAsFixed(1)}K';
|
|
} else {
|
|
return value.toStringAsFixed(0);
|
|
}
|
|
}
|
|
|
|
Future<void> _selectDateRange(BuildContext context) async {
|
|
final DateTimeRange? picked = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
initialDateRange: _dateRange ??
|
|
DateTimeRange(
|
|
start: DateTime.now().subtract(const Duration(days: 30)),
|
|
end: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (picked != null) {
|
|
setState(() {
|
|
_dateRange = picked;
|
|
});
|
|
}
|
|
}
|
|
|
|
void _toggleTodayFilter() {
|
|
setState(() {
|
|
_showOnlyToday = !_showOnlyToday;
|
|
if (_showOnlyToday) {
|
|
_dateRange = null; // Reset date range when showing only today
|
|
}
|
|
});
|
|
}
|
|
|
|
// NOUVELLES VARIABLES pour les filtres de sorties
|
|
DateTimeRange? _sortiesDateRange;
|
|
bool _sortiesShowOnlyToday = false;
|
|
|
|
// ... autres variables existantes
|
|
|
|
// NOUVELLE MÉTHODE: Sélectionner la période pour les sorties
|
|
Future<void> _selectSortiesDateRange(BuildContext context, StateSetter setDialogState) async {
|
|
final DateTimeRange? picked = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
initialDateRange: _sortiesDateRange ??
|
|
DateTimeRange(
|
|
start: DateTime.now().subtract(const Duration(days: 30)),
|
|
end: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (picked != null) {
|
|
setDialogState(() {
|
|
_sortiesDateRange = picked;
|
|
_sortiesShowOnlyToday = false;
|
|
});
|
|
}
|
|
}
|
|
|
|
// NOUVELLE MÉTHODE: Toggle du filtre "aujourd'hui" pour les sorties
|
|
void _toggleSortiesTodayFilter(StateSetter setDialogState) {
|
|
setDialogState(() {
|
|
_sortiesShowOnlyToday = !_sortiesShowOnlyToday;
|
|
if (_sortiesShowOnlyToday) {
|
|
_sortiesDateRange = null;
|
|
}
|
|
});
|
|
}
|
|
|
|
// MÉTHODE MISE À JOUR: _showPointVenteDetails avec les nouveaux filtres
|
|
void _showPointVenteDetails(Map<String, dynamic> pointVenteData) async {
|
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
|
final pointVenteId = pointVenteData['point_vente_id'] as int;
|
|
final pointVenteNom = pointVenteData['point_vente_nom'] as String;
|
|
|
|
// VARIABLE LOCALE pour gérer l'expansion des cartes dans ce dialog uniquement
|
|
Set<int> dialogExpandedCommandes = <int>{};
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setDialogState) => AlertDialog(
|
|
title: Text('Détails - $pointVenteNom'),
|
|
content: Container(
|
|
width: double.maxFinite,
|
|
height: 600,
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// FILTRES UNIFIÉS
|
|
Text('Filtres généraux:',
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14)),
|
|
SizedBox(height: 8),
|
|
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: [
|
|
ElevatedButton.icon(
|
|
onPressed: () {
|
|
setDialogState(() {
|
|
_showOnlyToday = !_showOnlyToday;
|
|
if (_showOnlyToday) _dateRange = null;
|
|
});
|
|
setState(() {}); // Pour rafraîchir le dashboard principal aussi
|
|
},
|
|
icon: Icon(
|
|
_showOnlyToday ? Icons.today : Icons.calendar_today,
|
|
size: 20,
|
|
),
|
|
label: Text(_showOnlyToday
|
|
? (isMobile ? 'Toutes dates' : 'Toutes les dates')
|
|
: (isMobile ? 'Aujourd\'hui' : 'Aujourd\'hui seulement')),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _showOnlyToday
|
|
? Colors.green.shade600
|
|
: Colors.blue.shade600,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isMobile ? 12 : 16,
|
|
vertical: 8,
|
|
),
|
|
),
|
|
),
|
|
ElevatedButton.icon(
|
|
onPressed: () async {
|
|
final DateTimeRange? picked = await showDateRangePicker(
|
|
context: context,
|
|
firstDate: DateTime(2020),
|
|
lastDate: DateTime.now().add(const Duration(days: 365)),
|
|
initialDateRange: _dateRange ??
|
|
DateTimeRange(
|
|
start: DateTime.now().subtract(const Duration(days: 30)),
|
|
end: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (picked != null) {
|
|
setDialogState(() {
|
|
_dateRange = picked;
|
|
_showOnlyToday = false;
|
|
});
|
|
setState(() {}); // Pour rafraîchir le dashboard principal aussi
|
|
}
|
|
},
|
|
icon: const Icon(Icons.date_range, size: 20),
|
|
label: Text(_dateRange != null
|
|
? (isMobile ? 'Période sélectionnée' : 'Période sélectionnée')
|
|
: (isMobile ? 'Choisir période' : 'Choisir période')),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _dateRange != null
|
|
? Colors.orange.shade600
|
|
: Colors.grey.shade600,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isMobile ? 12 : 16,
|
|
vertical: 8,
|
|
),
|
|
),
|
|
),
|
|
if (_showOnlyToday || _dateRange != null)
|
|
ElevatedButton.icon(
|
|
onPressed: () {
|
|
setDialogState(() {
|
|
_showOnlyToday = false;
|
|
_dateRange = null;
|
|
});
|
|
setState(() {}); // Pour rafraîchir le dashboard principal aussi
|
|
},
|
|
icon: const Icon(Icons.clear, size: 20),
|
|
label: Text('Reset', style: TextStyle(fontSize: 12)),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.red.shade600,
|
|
foregroundColor: Colors.white,
|
|
padding: EdgeInsets.symmetric(
|
|
horizontal: isMobile ? 10 : 12,
|
|
vertical: 8,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Affichage de la période sélectionnée
|
|
if (_showOnlyToday || _dateRange != null)
|
|
Container(
|
|
margin: EdgeInsets.only(top: 12, bottom: 8),
|
|
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
decoration: BoxDecoration(
|
|
color: (_showOnlyToday ? Colors.green : Colors.orange).withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: (_showOnlyToday ? Colors.green : Colors.orange).withOpacity(0.3),
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
_showOnlyToday ? Icons.today : Icons.date_range,
|
|
size: 16,
|
|
color: _showOnlyToday ? Colors.green : Colors.orange,
|
|
),
|
|
SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text(
|
|
_showOnlyToday
|
|
? 'Filtré sur aujourd\'hui'
|
|
: _dateRange != null
|
|
? 'Du ${DateFormat('dd/MM/yyyy').format(_dateRange!.start)} au ${DateFormat('dd/MM/yyyy').format(_dateRange!.end)}'
|
|
: 'Toutes les dates',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: _showOnlyToday ? Colors.green : Colors.orange,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
SizedBox(height: 12),
|
|
|
|
// Statistiques générales
|
|
FutureBuilder<Map<String, dynamic>>(
|
|
future: _getDetailedPointVenteStats(pointVenteId),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData) {
|
|
return Text('Erreur de chargement des statistiques');
|
|
}
|
|
|
|
final stats = snapshot.data!;
|
|
return Column(
|
|
children: [
|
|
_buildStatRow('Chiffre d\'affaires:', '${NumberFormat('#,##0.00', 'fr_FR').format((stats['chiffre_affaires'] as num?)?.toDouble() ?? 0.0)} MGA'),
|
|
_buildStatRow('Nombre de commandes:', '${stats['nombre_commandes'] ?? 0}'),
|
|
_buildStatRow('Articles vendus:', '${stats['nombre_articles_vendus'] ?? 0}'),
|
|
_buildStatRow('Quantité totale:', '${stats['quantite_totale_vendue'] ?? 0}'),
|
|
_buildStatRow('Panier moyen:', '${NumberFormat('#,##0.00', 'fr_FR').format((stats['panier_moyen'] as num?)?.toDouble() ?? 0.0)} MGA'),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
|
|
SizedBox(height: 12),
|
|
|
|
// SECTION: Liste des commandes
|
|
SizedBox(height: 16),
|
|
Divider(),
|
|
|
|
Text('Commandes récentes (max 10):',
|
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
|
|
SizedBox(height: 8),
|
|
|
|
FutureBuilder<List<Map<String, dynamic>>>(
|
|
future: _database.getCommandesParPointDeVente(
|
|
pointVenteId,
|
|
dateDebut: _dateRange?.start,
|
|
dateFin: _dateRange?.end,
|
|
aujourdHuiSeulement: _showOnlyToday,
|
|
limit: 10,
|
|
),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return Text('Erreur de chargement des commandes',
|
|
style: TextStyle(color: Colors.red));
|
|
}
|
|
|
|
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
|
String message = 'Aucune commande trouvée';
|
|
if (_showOnlyToday) {
|
|
message = 'Aucune commande aujourd\'hui';
|
|
} else if (_dateRange != null) {
|
|
message = 'Aucune commande pour cette période';
|
|
}
|
|
|
|
return Text(message, style: TextStyle(color: Colors.grey));
|
|
}
|
|
|
|
final commandes = snapshot.data!
|
|
.map((map) => Commande.fromMap(map))
|
|
.toList();
|
|
|
|
return Column(
|
|
children: commandes.map((commande) =>
|
|
_buildCommandeCardForDialog(commande, dialogExpandedCommandes, setDialogState)
|
|
).toList(),
|
|
);
|
|
},
|
|
),
|
|
|
|
// Section des sorties personnelles
|
|
SizedBox(height: 16),
|
|
Divider(),
|
|
|
|
Text('Historique des sorties personnelles:',
|
|
style: TextStyle(fontWeight: FontWeight.bold)),
|
|
|
|
SizedBox(height: 8),
|
|
|
|
FutureBuilder<List<Map<String, dynamic>>>(
|
|
future: _database.getHistoriqueSortiesPersonnelles(
|
|
pointDeVenteId: pointVenteId,
|
|
dateDebut: _dateRange?.start,
|
|
dateFin: _dateRange?.end,
|
|
aujourdHuiSeulement: _showOnlyToday,
|
|
limit: 10,
|
|
),
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return Text('Erreur de chargement des sorties',
|
|
style: TextStyle(color: Colors.red));
|
|
}
|
|
|
|
if (!snapshot.hasData || snapshot.data!.isEmpty) {
|
|
String message = 'Aucune sortie personnelle trouvée';
|
|
if (_showOnlyToday) {
|
|
message = 'Aucune sortie aujourd\'hui';
|
|
} else if (_dateRange != null) {
|
|
message = 'Aucune sortie pour cette période';
|
|
}
|
|
return Text(message, style: TextStyle(color: Colors.grey));
|
|
}
|
|
|
|
final sorties = snapshot.data!;
|
|
return Column(
|
|
children: sorties.map((sortie) => Card(
|
|
margin: EdgeInsets.symmetric(vertical: 2),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(6),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
sortie['produit_nom'] ?? 'N/A',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 14,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
Container(
|
|
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: _getStatusColorForSorties(sortie['statut']),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Text(
|
|
sortie['statut'] ?? 'N/A',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 3),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.inventory, size: 14, color: Colors.grey),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
'Qté: ${sortie['quantite'] ?? 0}',
|
|
style: TextStyle(fontSize: 14, color: Colors.black),
|
|
),
|
|
SizedBox(width: 128),
|
|
Icon(Icons.person, size: 14, color: Colors.grey),
|
|
SizedBox(width: 4),
|
|
Expanded(
|
|
child: Text(
|
|
'${sortie['admin_nom'] ?? ''} ${sortie['admin_nom_famille'] ?? ''}',
|
|
style: TextStyle(fontSize: 14, color: Colors.grey),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (sortie['date_sortie'] != null) ...[
|
|
SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.access_time, size: 14, color: Colors.grey),
|
|
SizedBox(width: 4),
|
|
Text(
|
|
_formatDateSortie(sortie['date_sortie']),
|
|
style: TextStyle(fontSize: 12, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
if (sortie['motif'] != null && sortie['motif'].toString().isNotEmpty) ...[
|
|
SizedBox(height: 3),
|
|
Text(
|
|
'Motif: ${sortie['motif']}',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey[600],
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
)).toList(),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: Text('Fermer'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
Widget _buildCommandeCardForDialog(
|
|
Commande commande,
|
|
Set<int> expandedCommandes,
|
|
StateSetter setDialogState,
|
|
) {
|
|
final int commandeId = commande.id ?? -1; // fallback si null
|
|
final bool isExpanded = expandedCommandes.contains(commandeId);
|
|
|
|
return FutureBuilder<List<DetailCommande>>(
|
|
future: _database.getDetailsCommande(commandeId),
|
|
builder: (context, snapshot) {
|
|
double totalRemises = 0;
|
|
bool aDesRemises = false;
|
|
|
|
if (snapshot.hasData) {
|
|
for (final detail in snapshot.data!) {
|
|
totalRemises += detail.montantRemise;
|
|
if (detail.aRemise) aDesRemises = true;
|
|
}
|
|
}
|
|
|
|
return AnimatedContainer(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
margin: const EdgeInsets.symmetric(vertical: 4),
|
|
child: Material(
|
|
elevation: isExpanded ? 4 : 2,
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isExpanded ? Colors.grey.shade100 : Colors.grey.shade200,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: aDesRemises
|
|
? Border.all(color: Colors.orange.shade300, width: 1)
|
|
: null,
|
|
),
|
|
child: InkWell(
|
|
onTap: () {
|
|
setDialogState(() {
|
|
if (isExpanded) {
|
|
expandedCommandes.remove(commandeId);
|
|
} else {
|
|
expandedCommandes.add(commandeId);
|
|
}
|
|
});
|
|
},
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// En-tête
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: _getStatutColor(commande.statut).withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: aDesRemises
|
|
? Border.all(color: Colors.orange.shade300)
|
|
: null,
|
|
),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
aDesRemises
|
|
? Icons.discount
|
|
: _getStatutIcon(commande.statut),
|
|
size: 16,
|
|
color: aDesRemises
|
|
? Colors.teal.shade700
|
|
: commande.statut == StatutCommande.annulee
|
|
? Colors.red
|
|
: Colors.blue.shade600,
|
|
),
|
|
Text(
|
|
'#$commandeId',
|
|
style: const TextStyle(
|
|
fontSize: 8,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
commande.clientNomComplet,
|
|
style: const TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 2),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.calendar_today,
|
|
size: 12, color: Colors.grey),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
DateFormat('dd/MM/yyyy').format(commande.dateCommande),
|
|
style: const TextStyle(fontSize: 11, color: Colors.grey),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: _getStatutColor(commande.statut).withOpacity(0.2),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Text(
|
|
commande.statutLibelle,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w600,
|
|
color: commande.statut == StatutCommande.annulee
|
|
? Colors.red
|
|
: Colors.blue.shade700,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
AnimatedRotation(
|
|
turns: isExpanded ? 0.5 : 0,
|
|
duration: const Duration(milliseconds: 300),
|
|
child: Icon(
|
|
Icons.expand_more,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Montant
|
|
Row(
|
|
children: [
|
|
Icon(Icons.attach_money,
|
|
size: 14, color: Colors.green.shade600),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'${NumberFormat('#,##0', 'fr_FR').format(commande.montantTotal)} MGA',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.green.shade700,
|
|
),
|
|
),
|
|
if (totalRemises > 0) ...[
|
|
const SizedBox(width: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade100,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.discount,
|
|
size: 10, color: Colors.teal.shade700),
|
|
const SizedBox(width: 2),
|
|
Text(
|
|
'-${NumberFormat('#,##0', 'fr_FR').format(totalRemises)}',
|
|
style: const TextStyle(
|
|
fontSize: 9,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
]
|
|
],
|
|
),
|
|
// Contenu étendu
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
child: isExpanded
|
|
? Column(
|
|
children: [
|
|
const SizedBox(height: 12),
|
|
Divider(color: Colors.grey[300]),
|
|
if (commande.commandeurnom?.isNotEmpty ?? false)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8.0),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.person,
|
|
size: 16, color: Colors.blue[600]),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'${commande.commandeurnom ?? ''} ${commande.commandeurPrenom ?? ''}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.blue[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
if (snapshot.hasData && snapshot.data!.isNotEmpty)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.shopping_cart,
|
|
size: 16, color: Colors.green[600]),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Produits commandés:',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.green[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
...snapshot.data!.map((detail) => Padding(
|
|
padding: const EdgeInsets.only(left: 24, bottom: 4),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.circle,
|
|
size: 4, color: Colors.green),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'${detail.produitNom} (x${detail.quantite})',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.green[700],
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'${NumberFormat('#,##0', 'fr_FR').format(detail.sousTotal)} MGA',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.green[700],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
))
|
|
],
|
|
),
|
|
],
|
|
)
|
|
: const SizedBox.shrink(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Color _getStatutColor(StatutCommande statut) {
|
|
switch (statut) {
|
|
case StatutCommande.enAttente:
|
|
return Colors.orange.shade100;
|
|
case StatutCommande.confirmee:
|
|
return Colors.blue.shade100;
|
|
case StatutCommande.annulee:
|
|
return Colors.red.shade100;
|
|
default:
|
|
return Colors.grey.shade100;
|
|
}
|
|
}
|
|
|
|
IconData _getStatutIcon(StatutCommande statut) {
|
|
switch (statut) {
|
|
case StatutCommande.enAttente:
|
|
return Icons.pending;
|
|
case StatutCommande.confirmee:
|
|
return Icons.check_circle;
|
|
case StatutCommande.annulee:
|
|
return Icons.cancel;
|
|
default:
|
|
return Icons.help;
|
|
}
|
|
}
|
|
|
|
|
|
// Méthodes utilitaires à ajouter dans votre classe
|
|
Color _getStatusColorForSorties(String? statut) {
|
|
switch (statut?.toLowerCase()) {
|
|
case 'en_attente':
|
|
return Colors.orange;
|
|
case 'approuve':
|
|
return Colors.green;
|
|
case 'refuse':
|
|
return Colors.red;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
|
|
// Version corrigée de _formatDate qui gère à la fois DateTime et String
|
|
String _formatDateSortie(dynamic dateValue) {
|
|
if (dateValue == null) return 'N/A';
|
|
|
|
DateTime? date;
|
|
|
|
// Gestion des différents types de données
|
|
if (dateValue is DateTime) {
|
|
date = dateValue;
|
|
} else if (dateValue is String) {
|
|
if (dateValue.isEmpty) return 'N/A';
|
|
|
|
try {
|
|
// Essayer différents formats de parsing
|
|
if (dateValue.contains('T')) {
|
|
// Format ISO 8601 (ex: 2024-01-15T10:30:00.000Z)
|
|
date = DateTime.parse(dateValue);
|
|
} else if (dateValue.contains('-') && dateValue.contains(':')) {
|
|
// Format personnalisé (ex: 15-01-2024 10:30:00)
|
|
final inputFormat = DateFormat('dd-MM-yyyy HH:mm:ss');
|
|
date = inputFormat.parse(dateValue);
|
|
} else {
|
|
// Essayer le parsing par défaut
|
|
date = DateTime.tryParse(dateValue);
|
|
}
|
|
} catch (e) {
|
|
print('Erreur de parsing de date: $e pour la valeur: $dateValue');
|
|
return 'Date invalide';
|
|
}
|
|
} else {
|
|
return 'Format de date non supporté';
|
|
}
|
|
|
|
if (date == null) return 'Date invalide';
|
|
|
|
try {
|
|
final outputFormat = DateFormat('dd/MM/yyyy HH:mm');
|
|
return outputFormat.format(date);
|
|
} catch (e) {
|
|
print('Erreur de formatage de date: $e');
|
|
return 'Erreur de format';
|
|
}
|
|
}
|
|
|
|
// Version alternative plus robuste
|
|
String _formatDateSortieAlternative(dynamic dateValue) {
|
|
if (dateValue == null) return 'N/A';
|
|
|
|
try {
|
|
DateTime date;
|
|
|
|
if (dateValue is DateTime) {
|
|
date = dateValue;
|
|
} else if (dateValue is String) {
|
|
// Convertir en DateTime selon différents formats possibles
|
|
if (dateValue.contains('T')) {
|
|
date = DateTime.parse(dateValue);
|
|
} else {
|
|
// Essayer le format dd-MM-yyyy HH:mm:ss
|
|
final parts = dateValue.split(' ');
|
|
if (parts.length >= 2) {
|
|
final datePart = parts[0];
|
|
final timePart = parts[1];
|
|
|
|
final dateComponents = datePart.split('-');
|
|
final timeComponents = timePart.split(':');
|
|
|
|
if (dateComponents.length == 3 && timeComponents.length >= 2) {
|
|
date = DateTime(
|
|
int.parse(dateComponents[2]), // année
|
|
int.parse(dateComponents[1]), // mois
|
|
int.parse(dateComponents[0]), // jour
|
|
int.parse(timeComponents[0]), // heure
|
|
int.parse(timeComponents[1]), // minute
|
|
timeComponents.length > 2 ? int.parse(timeComponents[2].split('.')[0]) : 0, // seconde
|
|
);
|
|
} else {
|
|
date = DateTime.now();
|
|
}
|
|
} else {
|
|
date = DateTime.parse(dateValue);
|
|
}
|
|
}
|
|
} else {
|
|
return 'Format non supporté';
|
|
}
|
|
|
|
return DateFormat('dd/MM/yyyy HH:mm').format(date);
|
|
} catch (e) {
|
|
print('Erreur de formatage de date: $e pour la valeur: $dateValue');
|
|
return 'Format invalide';
|
|
}
|
|
}
|
|
|
|
Future<Map<String, dynamic>> _getDetailedPointVenteStats(int pointVenteId) async {
|
|
final ventesData = await _database.getVentesParPointDeVente(
|
|
dateDebut: _dateRange?.start,
|
|
dateFin: _dateRange?.end,
|
|
aujourdHuiSeulement: _showOnlyToday,
|
|
);
|
|
|
|
final pointVenteStats = ventesData.firstWhere(
|
|
(data) => data['point_vente_id'] == pointVenteId,
|
|
orElse: () => {
|
|
'chiffre_affaires': 0.0,
|
|
'nombre_commandes': 0,
|
|
'nombre_articles_vendus': 0,
|
|
'quantite_totale_vendue': 0,
|
|
'panier_moyen': 0.0,
|
|
},
|
|
);
|
|
|
|
return pointVenteStats;
|
|
}
|
|
|
|
Widget _buildStatRow(String title, String value) {
|
|
return Container(
|
|
padding: EdgeInsets.symmetric(vertical: 2, horizontal: 4), // Padding minimal
|
|
child: Row(
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w500,
|
|
fontSize: 15,
|
|
),
|
|
),
|
|
SizedBox(width: 6), // Espace minimal entre titre et valeur
|
|
Expanded(
|
|
child: Text(
|
|
value,
|
|
textAlign: TextAlign.right,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: Colors.grey[700],
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
|
|
|
|
Widget _buildLowStockCard() {
|
|
|
|
return Card(
|
|
key: _lowStockKey,
|
|
elevation: 4,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.warning, color: Colors.orange),
|
|
SizedBox(width: 8),
|
|
Text(
|
|
'Produits en rupture',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
SizedBox(height: 8),
|
|
FutureBuilder<List<Product>>(
|
|
future: _lowStockProductsFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
|
return Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) {
|
|
return Padding(
|
|
padding: EdgeInsets.all(8),
|
|
child: Text('Aucun produit en rupture de stock'),
|
|
);
|
|
}
|
|
|
|
final products = snapshot.data!;
|
|
|
|
return Column(
|
|
children: products.map((product) => InkWell(
|
|
onTap: () {
|
|
// Animation et action au clic
|
|
_animationController.reset();
|
|
_animationController.forward();
|
|
// Vous pouvez ajouter une navigation vers le produit ici
|
|
},
|
|
borderRadius: BorderRadius.circular(8),
|
|
child: Container(
|
|
margin: EdgeInsets.only(bottom: 8),
|
|
padding: EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: product.image != null && product.image!.isNotEmpty
|
|
? CircleAvatar(
|
|
backgroundImage: NetworkImage(product.image!),
|
|
radius: 20,
|
|
)
|
|
: CircleAvatar(
|
|
backgroundColor: Colors.orange.shade100,
|
|
child: Icon(Icons.inventory, color: Colors.orange),
|
|
),
|
|
title: Text(
|
|
product.name,
|
|
style: TextStyle(fontSize: 14),
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Stock: ${product.stock ?? 0}',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
Text(
|
|
'Catégorie: ${product.category}',
|
|
style: TextStyle(fontSize: 10, color: Colors.grey),
|
|
),
|
|
],
|
|
),
|
|
trailing: Text(
|
|
'${NumberFormat('#,##0', 'fr_FR').format(product.price)} MGA',
|
|
style: TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
),
|
|
)).toList(),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Color _getStatusColor(StatutCommande status) {
|
|
switch (status) {
|
|
case StatutCommande.enAttente:
|
|
return Colors.orange;
|
|
case StatutCommande.confirmee:
|
|
return Colors.blue;
|
|
case StatutCommande.annulee:
|
|
return Colors.red;
|
|
default:
|
|
return Colors.grey;
|
|
}
|
|
}
|
|
}
|