diff --git a/Livrables/rapport-projet.md b/Livrables/rapport-projet.md index 7265541..31f5563 100644 --- a/Livrables/rapport-projet.md +++ b/Livrables/rapport-projet.md @@ -615,3 +615,70 @@ class Article { bool actif; // Soft delete } ``` + +--- + +## 5.7 Module Ventes + +### Objectif + +Le module Ventes couvre le **cycle de vente complet** de SUARL Rayhan, de la prise de commande client jusqu'à la livraison et la mise à jour automatique du stock. + +### Cycle de vente + +``` +Client passe commande + │ + POST /api/sales-orders + │ + Vérification stock disponible (backend) + │ + Calcul automatique HT / TVA 19% / TTC + │ + Commande → statut CONFIRMEE + │ + POST /api/sales-orders/{id}/deliver + │ + Bon de livraison généré (BL-XXXX-XXX) + │ + Sortie de stock automatique + │ + Commande → statut COMPLETEMENT_LIVREE +``` + +### Statuts d'une commande + +| Statut | Description | Couleur | +|--------|-------------|---------| +| CONFIRMEE | Commande validée, stock vérifié | Bleu | +| EN_PREPARATION | En cours de préparation | Orange | +| PARTIELLEMENT_LIVREE | Livraison partielle effectuée | Violet | +| COMPLETEMENT_LIVREE | Toutes les lignes livrées | Vert | +| ANNULEE | Commande annulée | Rouge | + +### Fonctionnalités Flutter + +**Écran liste (`VentesScreen`) :** +- Liste de toutes les commandes avec badge statut coloré +- Montant TTC visible directement sur la carte +- Accès au détail par tap + +**Formulaire création (`SalesOrderFormScreen`) :** +- Sélection du client (dropdown) +- Date de livraison souhaitée (sélecteur de date) +- Lignes dynamiques : ajout / suppression d'articles + - Sélection article (avec pré-remplissage du prix) + - Quantité + prix unitaire HT +- Calcul en temps réel du total HT / TVA / TTC +- Notes optionnelles + +**Écran détail (`SalesOrderDetailScreen`) :** +- Toutes les informations de la commande +- Liste des lignes avec quantités et montants +- Récapitulatif financier HT / TVA / TTC +- Bouton **Livrer** (affiché uniquement si la commande est livrable) +- Confirmation de livraison avec mise à jour automatique du stock + +### Sécurité stock + +Le backend valide avant chaque création de commande que le stock disponible est suffisant pour chaque ligne. En cas d'insuffisance, l'API retourne une erreur explicite affichée à l'utilisateur. diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 4e3fc63..37c3d19 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -5,9 +5,11 @@ import 'package:go_router/go_router.dart'; import 'providers/auth_provider.dart'; import 'providers/dashboard_provider.dart'; import 'providers/article_provider.dart'; +import 'providers/ventes_provider.dart'; import 'screens/login_screen.dart'; import 'screens/dashboard_screen.dart'; import 'screens/articles_screen.dart'; +import 'screens/ventes_screen.dart'; import 'widgets/app_drawer.dart'; void main() { @@ -17,6 +19,7 @@ void main() { ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => DashboardProvider()), ChangeNotifierProvider(create: (_) => ArticleProvider()), + ChangeNotifierProvider(create: (_) => VentesProvider()), ], child: const RayhanApp(), ), @@ -43,7 +46,7 @@ class RayhanApp extends StatelessWidget { GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()), GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()), - GoRoute(path: '/ventes', builder: (_, __) => const _PlaceholderScreen(title: 'Ventes', route: '/ventes')), + GoRoute(path: '/ventes', builder: (_, __) => const VentesScreen()), GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')), 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/client.dart b/frontend/lib/models/client.dart new file mode 100644 index 0000000..6a79f4f --- /dev/null +++ b/frontend/lib/models/client.dart @@ -0,0 +1,47 @@ +class Client { + final int? id; + final String raisonSociale; + final String? matriculeFiscal; + final String? telephone; + final String? email; + final String? adresse; + final String? ville; + final String? typeClient; + final bool actif; + + Client({ + this.id, + required this.raisonSociale, + this.matriculeFiscal, + this.telephone, + this.email, + this.adresse, + this.ville, + this.typeClient, + this.actif = true, + }); + + factory Client.fromJson(Map json) => Client( + id: json['id'], + raisonSociale: json['raisonSociale'] ?? '', + matriculeFiscal: json['matriculeFiscal'], + telephone: json['telephone'], + email: json['email'], + adresse: json['adresse'], + ville: json['ville'], + typeClient: json['typeClient'], + 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, + if (typeClient != null) 'typeClient': typeClient, + 'actif': actif, + }; +} diff --git a/frontend/lib/models/sales_order.dart b/frontend/lib/models/sales_order.dart new file mode 100644 index 0000000..0643070 --- /dev/null +++ b/frontend/lib/models/sales_order.dart @@ -0,0 +1,122 @@ +import 'client.dart'; +import 'article.dart'; + +class SalesOrderLine { + final int? id; + final Article? article; + final double quantiteCommandee; + final double quantiteLivree; + final double prixUnitaireHT; + final double tauxTVA; + final double? montantHT; + final double? montantTTC; + + SalesOrderLine({ + this.id, + this.article, + required this.quantiteCommandee, + this.quantiteLivree = 0, + required this.prixUnitaireHT, + this.tauxTVA = 19.0, + this.montantHT, + this.montantTTC, + }); + + factory SalesOrderLine.fromJson(Map json) => SalesOrderLine( + id: json['id'], + article: json['article'] != null + ? Article.fromJson(json['article']) + : null, + quantiteCommandee: (json['quantiteCommandee'] ?? 0).toDouble(), + quantiteLivree: (json['quantiteLivree'] ?? 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); +} + +class SalesOrder { + final int? id; + final String? reference; + final Client? client; + final String dateCommande; + final String? dateLivraisonSouhaitee; + final String statut; + final double totalHT; + final double totalTVA; + final double totalTTC; + final String? notes; + final List lignes; + + SalesOrder({ + this.id, + this.reference, + this.client, + required this.dateCommande, + this.dateLivraisonSouhaitee, + this.statut = 'CONFIRMEE', + this.totalHT = 0, + this.totalTVA = 0, + this.totalTTC = 0, + this.notes, + this.lignes = const [], + }); + + factory SalesOrder.fromJson(Map json) => SalesOrder( + id: json['id'], + reference: json['reference'], + client: json['client'] != null ? Client.fromJson(json['client']) : null, + dateCommande: json['dateCommande'] ?? '', + dateLivraisonSouhaitee: json['dateLivraisonSouhaitee'], + 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) => SalesOrderLine.fromJson(e)) + .toList(), + ); + + Map toJson() => { + if (client?.id != null) 'client': {'id': client!.id}, + 'dateCommande': dateCommande, + if (dateLivraisonSouhaitee != null) + 'dateLivraisonSouhaitee': dateLivraisonSouhaitee, + if (notes != null) 'notes': notes, + 'lignes': lignes.map((l) => l.toJson()).toList(), + }; + + static const Map statutLabels = { + 'CONFIRMEE': 'Confirmée', + 'EN_PREPARATION': 'En préparation', + 'PARTIELLEMENT_LIVREE': 'Part. livrée', + 'COMPLETEMENT_LIVREE': 'Livrée', + 'ANNULEE': 'Annulée', + }; + + static const Map statutColors = { + 'CONFIRMEE': 0xFF3B82F6, + 'EN_PREPARATION': 0xFFF59E0B, + 'PARTIELLEMENT_LIVREE': 0xFF8B5CF6, + 'COMPLETEMENT_LIVREE': 0xFF10B981, + 'ANNULEE': 0xFFEF4444, + }; + + String get statutLabel => statutLabels[statut] ?? statut; + int get statutColor => statutColors[statut] ?? 0xFF6B7280; + bool get peutLivrer => + statut == 'CONFIRMEE' || statut == 'EN_PREPARATION' || statut == 'PARTIELLEMENT_LIVREE'; +} diff --git a/frontend/lib/providers/ventes_provider.dart b/frontend/lib/providers/ventes_provider.dart new file mode 100644 index 0000000..6911c9a --- /dev/null +++ b/frontend/lib/providers/ventes_provider.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import '../models/sales_order.dart'; +import '../models/client.dart'; +import '../services/sales_order_service.dart'; +import '../services/client_service.dart'; + +class VentesProvider extends ChangeNotifier { + List _orders = []; + List _clients = []; + bool _isLoading = false; + String? _error; + + List get orders => _orders; + List get clients => _clients; + bool get isLoading => _isLoading; + String? get error => _error; + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + final results = await Future.wait([ + SalesOrderService.fetchAll(), + ClientService.fetchAll(), + ]); + _orders = results[0] as List; + _clients = results[1] as List; + } catch (_) { + _error = 'Impossible de charger les commandes.'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future createOrder(SalesOrder order) async { + try { + final created = await SalesOrderService.create(order); + _orders.insert(0, created); + notifyListeners(); + return null; + } catch (e) { + return e.toString().contains('Stock insuffisant') + ? 'Stock insuffisant pour un ou plusieurs articles' + : 'Erreur lors de la création de la commande'; + } + } + + Future deliver(int orderId, List lignes) async { + try { + final payload = { + 'dateLivraison': DateTime.now().toIso8601String().substring(0, 10), + 'lignes': lignes.map((l) => { + 'salesOrderLine': {'id': l.id}, + 'article': {'id': l.article!.id}, + 'quantiteLivree': l.quantiteCommandee, + }).toList(), + }; + await SalesOrderService.deliver(orderId, payload); + await load(); + return null; + } catch (e) { + return 'Erreur lors de la livraison'; + } + } +} diff --git a/frontend/lib/screens/sales_order_detail_screen.dart b/frontend/lib/screens/sales_order_detail_screen.dart new file mode 100644 index 0000000..d63f675 --- /dev/null +++ b/frontend/lib/screens/sales_order_detail_screen.dart @@ -0,0 +1,285 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../models/sales_order.dart'; +import '../providers/ventes_provider.dart'; + +class SalesOrderDetailScreen extends StatelessWidget { + final SalesOrder order; + const SalesOrderDetailScreen({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.peutLivrer) + Padding( + padding: const EdgeInsets.only(right: 12), + child: ElevatedButton.icon( + onPressed: () => _confirmDeliver(context), + icon: const Icon(Icons.local_shipping_outlined, size: 18), + label: const Text('Livrer'), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // En-tête statut + 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), + + // Infos générales + _InfoCard(children: [ + _InfoRow(label: 'Client', value: order.client?.raisonSociale ?? '—'), + _InfoRow(label: 'Date commande', value: order.dateCommande), + if (order.dateLivraisonSouhaitee != null) + _InfoRow(label: 'Livraison souhaitée', value: order.dateLivraisonSouhaitee!), + if (order.notes != null && order.notes!.isNotEmpty) + _InfoRow(label: 'Notes', value: order.notes!), + ]), + const SizedBox(height: 16), + + // Lignes + 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), + + // Totaux + 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(0xFF10B981)), + ], + ), + ), + const SizedBox(height: 32), + ], + ), + ); + } + + void _confirmDeliver(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Confirmer la livraison ?'), + content: Text( + 'Livrer toutes les lignes de la commande ${order.reference} ?\n\nLe stock sera mis à jour automatiquement.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981)), + onPressed: () async { + Navigator.pop(context); + final err = await context + .read() + .deliver(order.id!, order.lignes); + if (context.mounted) { + if (err == null) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Livraison 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 SalesOrderLine 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(0xFF10B981)), + ), + ], + ), + if (ligne.quantiteLivree > 0) + Text('Livré : ${ligne.quantiteLivree}', + 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/sales_order_form_screen.dart b/frontend/lib/screens/sales_order_form_screen.dart new file mode 100644 index 0000000..b2bf11e --- /dev/null +++ b/frontend/lib/screens/sales_order_form_screen.dart @@ -0,0 +1,485 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../providers/ventes_provider.dart'; +import '../providers/article_provider.dart'; +import '../models/client.dart'; +import '../models/article.dart'; +import '../models/sales_order.dart'; + +class SalesOrderFormScreen extends StatefulWidget { + const SalesOrderFormScreen({super.key}); + + @override + State createState() => _SalesOrderFormScreenState(); +} + +class _SalesOrderFormScreenState extends State { + final _formKey = GlobalKey(); + Client? _selectedClient; + 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(); + } + + void _addLigne() { + setState(() => _lignes.add(_LigneSaisie())); + } + + void _removeLigne(int i) { + setState(() { + _lignes[i].dispose(); + _lignes.removeAt(i); + }); + } + + 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 (_selectedClient == null) { + _showError('Veuillez sélectionner un client'); + 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) => SalesOrderLine( + article: l.article, + quantiteCommandee: double.tryParse(l.qteCtrl.text) ?? 0, + prixUnitaireHT: double.tryParse(l.prixCtrl.text) ?? 0, + tauxTVA: 19.0, + )).toList(); + + final order = SalesOrder( + client: _selectedClient, + dateCommande: DateTime.now().toIso8601String().substring(0, 10), + dateLivraisonSouhaitee: + _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 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 clients = context.watch().clients; + 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 vente', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Client + _SectionCard( + title: 'Client', + child: DropdownButtonFormField( + value: _selectedClient, + hint: const Text('Sélectionner un client'), + decoration: _deco('Client'), + items: clients + .map((c) => DropdownMenuItem( + value: c, + child: Text(c.raisonSociale), + )) + .toList(), + onChanged: (v) => setState(() => _selectedClient = v), + validator: (v) => v == null ? 'Obligatoire' : null, + ), + ), + const SizedBox(height: 12), + + // Date livraison souhaitée + _SectionCard( + title: 'Livraison souhaitée (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), + + // Lignes de commande + _SectionCard( + title: 'Lignes de commande', + child: Column( + children: [ + ..._lignes.asMap().entries.map((e) => _LigneWidget( + index: e.key, + ligne: e.value, + articles: articles, + onRemove: () => _removeLigne(e.key), + onChanged: () => setState(() {}), + )), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _addLigne, + icon: const Icon(Icons.add), + label: const Text('Ajouter une ligne'), + ), + ], + ), + ), + const SizedBox(height: 12), + + // Notes + _SectionCard( + title: 'Notes (optionnel)', + child: TextFormField( + controller: _notesCtrl, + maxLines: 3, + decoration: _deco('Notes / instructions').copyWith( + hintText: 'Remarques, conditions particulières…'), + ), + ), + const SizedBox(height: 12), + + // Total + if (_lignes.isNotEmpty) + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + children: [ + _TotalRow( + label: 'Total HT', value: fmt.format(_totalHT), white: true), + _TotalRow( + label: 'TVA (19%)', + value: fmt.format(_totalTTC - _totalHT), + white: true), + const Divider(color: Colors.white30), + _TotalRow( + label: 'Total TTC', + value: fmt.format(_totalTTC), + white: true, + bold: true), + ], + ), + ), + + const SizedBox(height: 24), + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + 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) { + return 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 white; + final bool bold; + const _TotalRow( + {required this.label, + required this.value, + this.white = false, + this.bold = false}); + + @override + Widget build(BuildContext context) { + final color = white ? Colors.white : const Color(0xFF374151); + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, + style: TextStyle( + color: white ? Colors.white70 : Colors.grey[600], + fontSize: 13)), + Text(value, + style: TextStyle( + color: color, + 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/screens/ventes_screen.dart b/frontend/lib/screens/ventes_screen.dart new file mode 100644 index 0000000..17dacff --- /dev/null +++ b/frontend/lib/screens/ventes_screen.dart @@ -0,0 +1,211 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../providers/ventes_provider.dart'; +import '../models/sales_order.dart'; +import '../widgets/app_drawer.dart'; +import 'sales_order_form_screen.dart'; +import 'sales_order_detail_screen.dart'; + +class VentesScreen extends StatefulWidget { + const VentesScreen({super.key}); + + @override + State createState() => _VentesScreenState(); +} + +class _VentesScreenState 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 Ventes', + 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: '/ventes'), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const SalesOrderFormScreen())), + icon: const Icon(Icons.add), + label: const Text('Nouvelle commande'), + ), + body: _buildBody(provider), + ); + } + + Widget _buildBody(VentesProvider 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.receipt_long_outlined, size: 64, color: Colors.grey[300]), + const SizedBox(height: 16), + Text('Aucune commande', + 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 SalesOrder 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: (_) => SalesOrderDetailScreen(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.client?.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(0xFF10B981)), + ), + ], + ), + 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/services/client_service.dart b/frontend/lib/services/client_service.dart new file mode 100644 index 0000000..0599911 --- /dev/null +++ b/frontend/lib/services/client_service.dart @@ -0,0 +1,14 @@ +import 'api_client.dart'; +import '../models/client.dart'; + +class ClientService { + static Future> fetchAll() async { + final res = await ApiClient.instance.get('/clients'); + return (res.data as List).map((e) => Client.fromJson(e)).toList(); + } + + static Future create(Client client) async { + final res = await ApiClient.instance.post('/clients', data: client.toJson()); + return Client.fromJson(res.data); + } +} diff --git a/frontend/lib/services/sales_order_service.dart b/frontend/lib/services/sales_order_service.dart new file mode 100644 index 0000000..a214814 --- /dev/null +++ b/frontend/lib/services/sales_order_service.dart @@ -0,0 +1,18 @@ +import 'api_client.dart'; +import '../models/sales_order.dart'; + +class SalesOrderService { + static Future> fetchAll() async { + final res = await ApiClient.instance.get('/sales-orders'); + return (res.data as List).map((e) => SalesOrder.fromJson(e)).toList(); + } + + static Future create(SalesOrder order) async { + final res = await ApiClient.instance.post('/sales-orders', data: order.toJson()); + return SalesOrder.fromJson(res.data); + } + + static Future deliver(int orderId, Map bonLivraison) async { + await ApiClient.instance.post('/sales-orders/$orderId/deliver', data: bonLivraison); + } +}