From 0811013abe5ee2d4dff042f2d18c5a20183ab318 Mon Sep 17 00:00:00 2001 From: Nabil Derouiche Date: Mon, 20 Apr 2026 20:35:41 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20module=20Achats=20complet=20(?= =?UTF-8?q?commandes,=20r=C3=A9ception,=20stock)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modèles Fournisseur, PurchaseOrder, PurchaseOrderLine - FournisseurService + PurchaseOrderService (create, receive) - AchatsProvider : chargement parallèle commandes + fournisseurs - AchatsScreen : liste avec badges statut colorés (violet) - PurchaseOrderFormScreen : lignes dynamiques, calcul HT/TVA/TTC - PurchaseOrderDetailScreen : détail + bouton Réceptionner - Réception → entrée stock automatique via backend Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/main.dart | 5 +- frontend/lib/models/fournisseur.dart | 55 +++ frontend/lib/models/purchase_order.dart | 122 ++++++ frontend/lib/providers/achats_provider.dart | 65 +++ frontend/lib/screens/achats_screen.dart | 189 +++++++++ .../screens/purchase_order_detail_screen.dart | 225 ++++++++++ .../screens/purchase_order_form_screen.dart | 397 ++++++++++++++++++ .../lib/services/fournisseur_service.dart | 14 + .../lib/services/purchase_order_service.dart | 18 + 9 files changed, 1089 insertions(+), 1 deletion(-) create mode 100644 frontend/lib/models/fournisseur.dart create mode 100644 frontend/lib/models/purchase_order.dart create mode 100644 frontend/lib/providers/achats_provider.dart create mode 100644 frontend/lib/screens/achats_screen.dart create mode 100644 frontend/lib/screens/purchase_order_detail_screen.dart create mode 100644 frontend/lib/screens/purchase_order_form_screen.dart create mode 100644 frontend/lib/services/fournisseur_service.dart create mode 100644 frontend/lib/services/purchase_order_service.dart diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 37c3d19..89d6bd5 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -6,10 +6,12 @@ import 'providers/auth_provider.dart'; import 'providers/dashboard_provider.dart'; import 'providers/article_provider.dart'; import 'providers/ventes_provider.dart'; +import 'providers/achats_provider.dart'; import 'screens/login_screen.dart'; import 'screens/dashboard_screen.dart'; import 'screens/articles_screen.dart'; import 'screens/ventes_screen.dart'; +import 'screens/achats_screen.dart'; import 'widgets/app_drawer.dart'; void main() { @@ -20,6 +22,7 @@ void main() { ChangeNotifierProvider(create: (_) => DashboardProvider()), ChangeNotifierProvider(create: (_) => ArticleProvider()), ChangeNotifierProvider(create: (_) => VentesProvider()), + ChangeNotifierProvider(create: (_) => AchatsProvider()), ], child: const RayhanApp(), ), @@ -47,7 +50,7 @@ class RayhanApp extends StatelessWidget { GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()), GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()), GoRoute(path: '/ventes', builder: (_, __) => const VentesScreen()), - GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')), + GoRoute(path: '/achats', builder: (_, __) => const AchatsScreen()), GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')), GoRoute(path: '/stock', builder: (_, __) => const _PlaceholderScreen(title: 'Stock', route: '/stock')), ], diff --git a/frontend/lib/models/fournisseur.dart b/frontend/lib/models/fournisseur.dart new file mode 100644 index 0000000..5da1127 --- /dev/null +++ b/frontend/lib/models/fournisseur.dart @@ -0,0 +1,55 @@ +class Fournisseur { + final int? id; + final String raisonSociale; + final String? matriculeFiscal; + final String? telephone; + final String? email; + final String? adresse; + final String? ville; + final String? pays; + final String? categorieProduit; + final String? modePaiement; + final bool actif; + + Fournisseur({ + this.id, + required this.raisonSociale, + this.matriculeFiscal, + this.telephone, + this.email, + this.adresse, + this.ville, + this.pays = 'Tunisie', + this.categorieProduit, + this.modePaiement, + this.actif = true, + }); + + factory Fournisseur.fromJson(Map json) => Fournisseur( + id: json['id'], + raisonSociale: json['raisonSociale'] ?? '', + matriculeFiscal: json['matriculeFiscal'], + telephone: json['telephone'], + email: json['email'], + adresse: json['adresse'], + ville: json['ville'], + pays: json['pays'] ?? 'Tunisie', + categorieProduit: json['categorieProduit'], + modePaiement: json['modePaiement'], + actif: json['actif'] ?? true, + ); + + Map toJson() => { + if (id != null) 'id': id, + 'raisonSociale': raisonSociale, + if (matriculeFiscal != null) 'matriculeFiscal': matriculeFiscal, + if (telephone != null) 'telephone': telephone, + if (email != null) 'email': email, + if (adresse != null) 'adresse': adresse, + if (ville != null) 'ville': ville, + 'pays': pays ?? 'Tunisie', + if (categorieProduit != null) 'categorieProduit': categorieProduit, + if (modePaiement != null) 'modePaiement': modePaiement, + 'actif': actif, + }; +} diff --git a/frontend/lib/models/purchase_order.dart b/frontend/lib/models/purchase_order.dart new file mode 100644 index 0000000..fab29b7 --- /dev/null +++ b/frontend/lib/models/purchase_order.dart @@ -0,0 +1,122 @@ +import 'fournisseur.dart'; +import 'article.dart'; + +class PurchaseOrderLine { + final int? id; + final Article? article; + final double quantiteCommandee; + final double quantiteRecue; + final double prixUnitaireHT; + final double tauxTVA; + final double? montantHT; + final double? montantTTC; + + PurchaseOrderLine({ + this.id, + this.article, + required this.quantiteCommandee, + this.quantiteRecue = 0, + required this.prixUnitaireHT, + this.tauxTVA = 19.0, + this.montantHT, + this.montantTTC, + }); + + factory PurchaseOrderLine.fromJson(Map json) => PurchaseOrderLine( + id: json['id'], + article: json['article'] != null ? Article.fromJson(json['article']) : null, + quantiteCommandee: (json['quantiteCommandee'] ?? 0).toDouble(), + quantiteRecue: (json['quantiteRecue'] ?? 0).toDouble(), + prixUnitaireHT: (json['prixUnitaireHT'] ?? 0).toDouble(), + tauxTVA: (json['tauxTVA'] ?? 19.0).toDouble(), + montantHT: json['montantHT']?.toDouble(), + montantTTC: json['montantTTC']?.toDouble(), + ); + + Map toJson() => { + if (id != null) 'id': id, + if (article?.id != null) 'article': {'id': article!.id}, + 'quantiteCommandee': quantiteCommandee, + 'prixUnitaireHT': prixUnitaireHT, + 'tauxTVA': tauxTVA, + }; + + double get montantHTCalc => quantiteCommandee * prixUnitaireHT; + double get montantTTCCalc => montantHTCalc * (1 + tauxTVA / 100); + bool get estRecu => quantiteRecue >= quantiteCommandee; +} + +class PurchaseOrder { + final int? id; + final String? reference; + final Fournisseur? fournisseur; + final String dateCommande; + final String? dateLivraisonPrevue; + final String statut; + final double totalHT; + final double totalTVA; + final double totalTTC; + final String? notes; + final List lignes; + + PurchaseOrder({ + this.id, + this.reference, + this.fournisseur, + required this.dateCommande, + this.dateLivraisonPrevue, + this.statut = 'CONFIRMEE', + this.totalHT = 0, + this.totalTVA = 0, + this.totalTTC = 0, + this.notes, + this.lignes = const [], + }); + + factory PurchaseOrder.fromJson(Map json) => PurchaseOrder( + id: json['id'], + reference: json['reference'], + fournisseur: json['fournisseur'] != null + ? Fournisseur.fromJson(json['fournisseur']) + : null, + dateCommande: json['dateCommande'] ?? '', + dateLivraisonPrevue: json['dateLivraisonPrevue'], + statut: json['statut'] ?? 'CONFIRMEE', + totalHT: (json['totalHT'] ?? 0).toDouble(), + totalTVA: (json['totalTVA'] ?? 0).toDouble(), + totalTTC: (json['totalTTC'] ?? 0).toDouble(), + notes: json['notes'], + lignes: (json['lignes'] as List? ?? []) + .map((e) => PurchaseOrderLine.fromJson(e)) + .toList(), + ); + + Map toJson() => { + if (fournisseur?.id != null) 'fournisseur': {'id': fournisseur!.id}, + 'dateCommande': dateCommande, + if (dateLivraisonPrevue != null) 'dateLivraisonPrevue': dateLivraisonPrevue, + if (notes != null) 'notes': notes, + 'lignes': lignes.map((l) => l.toJson()).toList(), + }; + + static const Map statutLabels = { + 'BROUILLON': 'Brouillon', + 'CONFIRMEE': 'Confirmée', + 'PARTIELLEMENT_RECUE': 'Part. reçue', + 'COMPLETEMENT_RECUE': 'Reçue', + 'ANNULEE': 'Annulée', + }; + + static const Map statutColors = { + 'BROUILLON': 0xFF9CA3AF, + 'CONFIRMEE': 0xFF3B82F6, + 'PARTIELLEMENT_RECUE': 0xFF8B5CF6, + 'COMPLETEMENT_RECUE': 0xFF10B981, + 'ANNULEE': 0xFFEF4444, + }; + + String get statutLabel => statutLabels[statut] ?? statut; + int get statutColor => statutColors[statut] ?? 0xFF6B7280; + bool get peutReceptionner => + statut == 'CONFIRMEE' || statut == 'PARTIELLEMENT_RECUE'; +} diff --git a/frontend/lib/providers/achats_provider.dart b/frontend/lib/providers/achats_provider.dart new file mode 100644 index 0000000..6a7c829 --- /dev/null +++ b/frontend/lib/providers/achats_provider.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import '../models/purchase_order.dart'; +import '../models/fournisseur.dart'; +import '../services/purchase_order_service.dart'; +import '../services/fournisseur_service.dart'; + +class AchatsProvider extends ChangeNotifier { + List _orders = []; + List _fournisseurs = []; + bool _isLoading = false; + String? _error; + + List get orders => _orders; + List get fournisseurs => _fournisseurs; + bool get isLoading => _isLoading; + String? get error => _error; + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + final results = await Future.wait([ + PurchaseOrderService.fetchAll(), + FournisseurService.fetchAll(), + ]); + _orders = results[0] as List; + _fournisseurs = results[1] as List; + } catch (_) { + _error = 'Impossible de charger les commandes achats.'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future createOrder(PurchaseOrder order) async { + try { + final created = await PurchaseOrderService.create(order); + _orders.insert(0, created); + notifyListeners(); + return null; + } catch (_) { + return 'Erreur lors de la création de la commande'; + } + } + + Future receive(int orderId, List lignes) async { + try { + final payload = { + 'dateReception': DateTime.now().toIso8601String().substring(0, 10), + 'lignes': lignes.map((l) => { + 'purchaseOrderLine': {'id': l.id}, + 'article': {'id': l.article!.id}, + 'quantiteRecue': l.quantiteCommandee, + }).toList(), + }; + await PurchaseOrderService.receive(orderId, payload); + await load(); + return null; + } catch (_) { + return 'Erreur lors de la réception'; + } + } +} diff --git a/frontend/lib/screens/achats_screen.dart b/frontend/lib/screens/achats_screen.dart new file mode 100644 index 0000000..7254856 --- /dev/null +++ b/frontend/lib/screens/achats_screen.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../providers/achats_provider.dart'; +import '../models/purchase_order.dart'; +import '../widgets/app_drawer.dart'; +import 'purchase_order_form_screen.dart'; +import 'purchase_order_detail_screen.dart'; + +class AchatsScreen extends StatefulWidget { + const AchatsScreen({super.key}); + + @override + State createState() => _AchatsScreenState(); +} + +class _AchatsScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().load(); + }); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Commandes Achats', + style: TextStyle(fontWeight: FontWeight.bold)), + if (!provider.isLoading) + Text('${provider.orders.length} commande(s)', + style: TextStyle(fontSize: 11, color: Colors.grey[500])), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () => context.read().load(), + ), + ], + ), + drawer: const AppDrawer(currentRoute: '/achats'), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const PurchaseOrderFormScreen())), + icon: const Icon(Icons.add), + label: const Text('Nouvelle commande'), + ), + body: _buildBody(provider), + ); + } + + Widget _buildBody(AchatsProvider provider) { + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + if (provider.error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.grey[400]), + const SizedBox(height: 12), + Text(provider.error!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.read().load(), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + if (provider.orders.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.local_shipping_outlined, size: 64, color: Colors.grey[300]), + const SizedBox(height: 16), + Text('Aucune commande achat', + style: TextStyle(color: Colors.grey[500], fontSize: 16)), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () => context.read().load(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: provider.orders.length, + itemBuilder: (ctx, i) => _OrderCard(order: provider.orders[i]), + ), + ); + } +} + +class _OrderCard extends StatelessWidget { + final PurchaseOrder order; + const _OrderCard({required this.order}); + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat.currency(locale: 'fr_TN', symbol: 'TND', decimalDigits: 3); + final color = Color(order.statutColor); + + return GestureDetector( + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => PurchaseOrderDetailScreen(order: order))), + child: Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text(order.reference ?? '—', + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(20), + ), + child: Text(order.statutLabel, + style: TextStyle(color: color, fontSize: 11, fontWeight: FontWeight.w600)), + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + const Icon(Icons.business_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text(order.fournisseur?.raisonSociale ?? '—', + style: TextStyle(color: Colors.grey[700], fontSize: 13)), + ), + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + const Icon(Icons.calendar_today_outlined, size: 13, color: Colors.grey), + const SizedBox(width: 4), + Text(order.dateCommande, + style: TextStyle(color: Colors.grey[600], fontSize: 12)), + ], + ), + Text(fmt.format(order.totalTTC), + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 14, color: Color(0xFF8B5CF6))), + ], + ), + if (order.lignes.isNotEmpty) ...[ + const SizedBox(height: 6), + Text('${order.lignes.length} ligne(s)', + style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/screens/purchase_order_detail_screen.dart b/frontend/lib/screens/purchase_order_detail_screen.dart new file mode 100644 index 0000000..620aa18 --- /dev/null +++ b/frontend/lib/screens/purchase_order_detail_screen.dart @@ -0,0 +1,225 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../models/purchase_order.dart'; +import '../providers/achats_provider.dart'; + +class PurchaseOrderDetailScreen extends StatelessWidget { + final PurchaseOrder order; + const PurchaseOrderDetailScreen({super.key, required this.order}); + + @override + Widget build(BuildContext context) { + final fmt = NumberFormat.currency(locale: 'fr_TN', symbol: 'TND', decimalDigits: 3); + final statusColor = Color(order.statutColor); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Text(order.reference ?? '—', + style: const TextStyle(fontWeight: FontWeight.bold)), + actions: [ + if (order.peutReceptionner) + Padding( + padding: const EdgeInsets.only(right: 12), + child: ElevatedButton.icon( + onPressed: () => _confirmReceive(context), + icon: const Icon(Icons.inventory_outlined, size: 18), + label: const Text('Réceptionner'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8B5CF6), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: statusColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: statusColor.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.circle, color: statusColor, size: 10), + const SizedBox(width: 8), + Text(order.statutLabel, + style: TextStyle(color: statusColor, fontWeight: FontWeight.bold, fontSize: 14)), + ], + ), + ), + const SizedBox(height: 16), + _InfoCard(children: [ + _InfoRow(label: 'Fournisseur', value: order.fournisseur?.raisonSociale ?? '—'), + _InfoRow(label: 'Date commande', value: order.dateCommande), + if (order.dateLivraisonPrevue != null) + _InfoRow(label: 'Livraison prévue', value: order.dateLivraisonPrevue!), + if (order.notes != null && order.notes!.isNotEmpty) + _InfoRow(label: 'Notes', value: order.notes!), + ]), + const SizedBox(height: 16), + const Text('Lignes de commande', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14)), + const SizedBox(height: 8), + ...order.lignes.map((l) => _LigneCard(ligne: l, fmt: fmt)), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column( + children: [ + _TotalRow(label: 'Total HT', value: fmt.format(order.totalHT)), + _TotalRow(label: 'TVA (19%)', value: fmt.format(order.totalTVA)), + const Divider(), + _TotalRow(label: 'Total TTC', value: fmt.format(order.totalTTC), + bold: true, color: const Color(0xFF8B5CF6)), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ); + } + + void _confirmReceive(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Confirmer la réception ?'), + content: Text( + 'Réceptionner toutes les lignes de ${order.reference} ?\n\nLe stock sera incrémenté automatiquement.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B5CF6)), + onPressed: () async { + Navigator.pop(context); + final err = await context.read().receive(order.id!, order.lignes); + if (context.mounted) { + if (err == null) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Réception enregistrée — stock mis à jour'), + backgroundColor: Colors.green, + )); + } else { + ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(err), backgroundColor: Colors.red)); + } + } + }, + child: const Text('Confirmer', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } +} + +class _InfoCard extends StatelessWidget { + final List children; + const _InfoCard({required this.children}); + @override + Widget build(BuildContext context) => Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column(children: children), + ); +} + +class _InfoRow extends StatelessWidget { + final String label; + final String value; + const _InfoRow({required this.label, required this.value}); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 130, + child: Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 13))), + Expanded(child: Text(value, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13))), + ], + ), + ); +} + +class _LigneCard extends StatelessWidget { + final PurchaseOrderLine ligne; + final NumberFormat fmt; + const _LigneCard({required this.ligne, required this.fmt}); + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 6, offset: const Offset(0, 1))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(ligne.article?.designation ?? '—', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${ligne.quantiteCommandee} ${ligne.article?.uniteMesure ?? ''} × ${fmt.format(ligne.prixUnitaireHT)}', + style: TextStyle(color: Colors.grey[600], fontSize: 12), + ), + Text(fmt.format(ligne.montantTTC ?? ligne.montantTTCCalc), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13, color: Color(0xFF8B5CF6))), + ], + ), + if (ligne.quantiteRecue > 0) + Text('Reçu : ${ligne.quantiteRecue}', + style: const TextStyle(color: Color(0xFF10B981), fontSize: 11)), + ], + ), + ); +} + +class _TotalRow extends StatelessWidget { + final String label; + final String value; + final bool bold; + final Color? color; + const _TotalRow({required this.label, required this.value, this.bold = false, this.color}); + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 13)), + Text(value, style: TextStyle( + fontWeight: bold ? FontWeight.bold : FontWeight.normal, + fontSize: bold ? 16 : 13, + color: color ?? const Color(0xFF374151))), + ], + ), + ); +} diff --git a/frontend/lib/screens/purchase_order_form_screen.dart b/frontend/lib/screens/purchase_order_form_screen.dart new file mode 100644 index 0000000..796e047 --- /dev/null +++ b/frontend/lib/screens/purchase_order_form_screen.dart @@ -0,0 +1,397 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../providers/achats_provider.dart'; +import '../providers/article_provider.dart'; +import '../models/fournisseur.dart'; +import '../models/article.dart'; +import '../models/purchase_order.dart'; + +class PurchaseOrderFormScreen extends StatefulWidget { + const PurchaseOrderFormScreen({super.key}); + + @override + State createState() => _PurchaseOrderFormScreenState(); +} + +class _PurchaseOrderFormScreenState extends State { + final _formKey = GlobalKey(); + Fournisseur? _selectedFournisseur; + DateTime? _dateLivraison; + final _notesCtrl = TextEditingController(); + final List<_LigneSaisie> _lignes = []; + bool _saving = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.read().articles.isEmpty) { + context.read().load(); + } + }); + } + + @override + void dispose() { + _notesCtrl.dispose(); + for (final l in _lignes) l.dispose(); + super.dispose(); + } + + double get _totalHT => _lignes.fold(0, (s, l) { + final qte = double.tryParse(l.qteCtrl.text) ?? 0; + final prix = double.tryParse(l.prixCtrl.text) ?? 0; + return s + qte * prix; + }); + + double get _totalTTC => _totalHT * 1.19; + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedFournisseur == null) { + _showError('Veuillez sélectionner un fournisseur'); + return; + } + if (_lignes.isEmpty) { + _showError('Ajoutez au moins une ligne'); + return; + } + for (int i = 0; i < _lignes.length; i++) { + if (_lignes[i].article == null) { + _showError('Sélectionnez un article pour la ligne ${i + 1}'); + return; + } + } + + setState(() => _saving = true); + + final lignes = _lignes.map((l) => PurchaseOrderLine( + article: l.article, + quantiteCommandee: double.tryParse(l.qteCtrl.text) ?? 0, + prixUnitaireHT: double.tryParse(l.prixCtrl.text) ?? 0, + tauxTVA: 19.0, + )).toList(); + + final order = PurchaseOrder( + fournisseur: _selectedFournisseur, + dateCommande: DateTime.now().toIso8601String().substring(0, 10), + dateLivraisonPrevue: _dateLivraison?.toIso8601String().substring(0, 10), + notes: _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(), + lignes: lignes, + ); + + final err = await context.read().createOrder(order); + if (mounted) { + setState(() => _saving = false); + if (err == null) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Commande achat créée avec succès'), + backgroundColor: Colors.green, + )); + } else { + _showError(err); + } + } + } + + void _showError(String msg) => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red)); + + @override + Widget build(BuildContext context) { + final fournisseurs = context.watch().fournisseurs; + final articles = context.watch().articles; + final fmt = NumberFormat.currency(locale: 'fr_TN', symbol: 'TND', decimalDigits: 3); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text('Nouvelle commande achat', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _SectionCard( + title: 'Fournisseur', + child: DropdownButtonFormField( + value: _selectedFournisseur, + hint: const Text('Sélectionner un fournisseur'), + decoration: _deco('Fournisseur'), + items: fournisseurs.map((f) => DropdownMenuItem( + value: f, + child: Text(f.raisonSociale), + )).toList(), + onChanged: (v) => setState(() => _selectedFournisseur = v), + validator: (v) => v == null ? 'Obligatoire' : null, + ), + ), + const SizedBox(height: 12), + _SectionCard( + title: 'Livraison prévue (optionnel)', + child: GestureDetector( + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: DateTime.now().add(const Duration(days: 7)), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (d != null) setState(() => _dateLivraison = d); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + color: const Color(0xFFF8F9FA), + border: Border.all(color: Colors.grey[400]!), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + const Icon(Icons.calendar_today_outlined, size: 18, color: Colors.grey), + const SizedBox(width: 8), + Text( + _dateLivraison != null + ? DateFormat('dd/MM/yyyy').format(_dateLivraison!) + : 'Choisir une date', + style: TextStyle( + color: _dateLivraison != null ? Colors.black87 : Colors.grey[600]), + ), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + _SectionCard( + title: 'Lignes de commande', + child: Column( + children: [ + ..._lignes.asMap().entries.map((e) => _LigneWidget( + index: e.key, + ligne: e.value, + articles: articles, + onRemove: () => setState(() { + _lignes[e.key].dispose(); + _lignes.removeAt(e.key); + }), + onChanged: () => setState(() {}), + )), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: () => setState(() => _lignes.add(_LigneSaisie())), + icon: const Icon(Icons.add), + label: const Text('Ajouter une ligne'), + ), + ], + ), + ), + const SizedBox(height: 12), + _SectionCard( + title: 'Notes (optionnel)', + child: TextFormField( + controller: _notesCtrl, + maxLines: 3, + decoration: _deco('Notes').copyWith(hintText: 'Conditions, remarques…'), + ), + ), + const SizedBox(height: 12), + if (_lignes.isNotEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF8B5CF6), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + _TotalRow(label: 'Total HT', value: fmt.format(_totalHT)), + _TotalRow(label: 'TVA (19%)', value: fmt.format(_totalTTC - _totalHT)), + const Divider(color: Colors.white30), + _TotalRow(label: 'Total TTC', value: fmt.format(_totalTTC), bold: true), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF8B5CF6), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + child: _saving + ? const SizedBox( + width: 22, height: 22, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Créer la commande', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } +} + +class _LigneWidget extends StatelessWidget { + final int index; + final _LigneSaisie ligne; + final List
articles; + final VoidCallback onRemove; + final VoidCallback onChanged; + + const _LigneWidget({ + required this.index, required this.ligne, required this.articles, + required this.onRemove, required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: const Color(0xFFF5F7FA), + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey[300]!), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text('Ligne ${index + 1}', + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 12)), + const Spacer(), + GestureDetector(onTap: onRemove, + child: const Icon(Icons.close, size: 18, color: Colors.red)), + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField
( + value: ligne.article, + hint: const Text('Article', style: TextStyle(fontSize: 13)), + decoration: _deco('Article').copyWith( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10)), + items: articles.map((a) => DropdownMenuItem( + value: a, + child: Text('${a.reference} — ${a.designation}', + style: const TextStyle(fontSize: 13)), + )).toList(), + onChanged: (a) { + ligne.article = a; + if (a != null) ligne.prixCtrl.text = a.prixUnitaire.toString(); + onChanged(); + }, + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: TextFormField( + controller: ligne.qteCtrl, + keyboardType: TextInputType.number, + decoration: _deco('Quantité').copyWith( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10)), + onChanged: (_) => onChanged(), + validator: (v) { + if (v == null || v.isEmpty) return 'Requis'; + if ((double.tryParse(v) ?? 0) <= 0) return '> 0'; + return null; + }, + ), + ), + const SizedBox(width: 8), + Expanded( + child: TextFormField( + controller: ligne.prixCtrl, + keyboardType: TextInputType.number, + decoration: _deco('Prix HT').copyWith( + contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10)), + onChanged: (_) => onChanged(), + validator: (v) { + if (v == null || v.isEmpty) return 'Requis'; + if ((double.tryParse(v) ?? 0) <= 0) return '> 0'; + return null; + }, + ), + ), + ], + ), + ], + ), + ); + } +} + +class _LigneSaisie { + Article? article; + final qteCtrl = TextEditingController(); + final prixCtrl = TextEditingController(); + void dispose() { qteCtrl.dispose(); prixCtrl.dispose(); } +} + +class _SectionCard extends StatelessWidget { + final String title; + final Widget child; + const _SectionCard({required this.title, required this.child}); + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 8, offset: const Offset(0, 2))], + ), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12, color: Color(0xFF6B7280))), + const SizedBox(height: 10), + child, + ], + ), + ); +} + +class _TotalRow extends StatelessWidget { + final String label; + final String value; + final bool bold; + const _TotalRow({required this.label, required this.value, this.bold = false}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: const TextStyle(color: Colors.white70, fontSize: 13)), + Text(value, style: TextStyle( + color: Colors.white, fontWeight: bold ? FontWeight.bold : FontWeight.normal, + fontSize: bold ? 16 : 13)), + ], + ), + ); +} + +InputDecoration _deco(String label) => InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + filled: true, + fillColor: const Color(0xFFF8F9FA), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ); diff --git a/frontend/lib/services/fournisseur_service.dart b/frontend/lib/services/fournisseur_service.dart new file mode 100644 index 0000000..cb3757f --- /dev/null +++ b/frontend/lib/services/fournisseur_service.dart @@ -0,0 +1,14 @@ +import 'api_client.dart'; +import '../models/fournisseur.dart'; + +class FournisseurService { + static Future> fetchAll() async { + final res = await ApiClient.instance.get('/fournisseurs'); + return (res.data as List).map((e) => Fournisseur.fromJson(e)).toList(); + } + + static Future create(Fournisseur f) async { + final res = await ApiClient.instance.post('/fournisseurs', data: f.toJson()); + return Fournisseur.fromJson(res.data); + } +} diff --git a/frontend/lib/services/purchase_order_service.dart b/frontend/lib/services/purchase_order_service.dart new file mode 100644 index 0000000..527de1a --- /dev/null +++ b/frontend/lib/services/purchase_order_service.dart @@ -0,0 +1,18 @@ +import 'api_client.dart'; +import '../models/purchase_order.dart'; + +class PurchaseOrderService { + static Future> fetchAll() async { + final res = await ApiClient.instance.get('/purchase-orders'); + return (res.data as List).map((e) => PurchaseOrder.fromJson(e)).toList(); + } + + static Future create(PurchaseOrder order) async { + final res = await ApiClient.instance.post('/purchase-orders', data: order.toJson()); + return PurchaseOrder.fromJson(res.data); + } + + static Future receive(int orderId, Map reception) async { + await ApiClient.instance.post('/purchase-orders/$orderId/receive', data: reception); + } +}