From acbf3a16001e7898be3fffecd2e8b915924b3b6e Mon Sep 17 00:00:00 2001 From: Nabil Derouiche Date: Mon, 20 Apr 2026 20:39:05 +0100 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20module=20Production=20complet?= =?UTF-8?q?=20(BOM,=20OF,=20cycle=20PLANIFIE=E2=86=92LANCE=E2=86=92TERMINE?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modèles ProductionOrder, BomLine - ProductionService : plan, launch, complete, getBom - ProductionProvider : load, plan, launch, complete + stats rapides - ProductionScreen : liste avec filtre statut + actions rapides sur carte - ProductionFormScreen : sélection PF, affichage BOM dynamique, vérif stock - ProductionDetailScreen : infos, lancement OF, clôture avec qté réalisée - Cycle complet : MP consommées au lancement, PF entré en stock à la clôture Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/main.dart | 5 +- frontend/lib/models/production_order.dart | 90 +++++ .../lib/providers/production_provider.dart | 80 +++++ .../lib/screens/production_detail_screen.dart | 271 ++++++++++++++ .../lib/screens/production_form_screen.dart | 313 ++++++++++++++++ frontend/lib/screens/production_screen.dart | 333 ++++++++++++++++++ frontend/lib/services/production_service.dart | 39 ++ 7 files changed, 1130 insertions(+), 1 deletion(-) create mode 100644 frontend/lib/models/production_order.dart create mode 100644 frontend/lib/providers/production_provider.dart create mode 100644 frontend/lib/screens/production_detail_screen.dart create mode 100644 frontend/lib/screens/production_form_screen.dart create mode 100644 frontend/lib/screens/production_screen.dart create mode 100644 frontend/lib/services/production_service.dart diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 89d6bd5..4b3bf04 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -7,11 +7,13 @@ import 'providers/dashboard_provider.dart'; import 'providers/article_provider.dart'; import 'providers/ventes_provider.dart'; import 'providers/achats_provider.dart'; +import 'providers/production_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 'screens/production_screen.dart'; import 'widgets/app_drawer.dart'; void main() { @@ -23,6 +25,7 @@ void main() { ChangeNotifierProvider(create: (_) => ArticleProvider()), ChangeNotifierProvider(create: (_) => VentesProvider()), ChangeNotifierProvider(create: (_) => AchatsProvider()), + ChangeNotifierProvider(create: (_) => ProductionProvider()), ], child: const RayhanApp(), ), @@ -51,7 +54,7 @@ class RayhanApp extends StatelessWidget { GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()), GoRoute(path: '/ventes', builder: (_, __) => const VentesScreen()), GoRoute(path: '/achats', builder: (_, __) => const AchatsScreen()), - GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')), + GoRoute(path: '/production', builder: (_, __) => const ProductionScreen()), GoRoute(path: '/stock', builder: (_, __) => const _PlaceholderScreen(title: 'Stock', route: '/stock')), ], ); diff --git a/frontend/lib/models/production_order.dart b/frontend/lib/models/production_order.dart new file mode 100644 index 0000000..1d18e57 --- /dev/null +++ b/frontend/lib/models/production_order.dart @@ -0,0 +1,90 @@ +import 'article.dart'; + +class BomLine { + final int? id; + final Article? composant; + final double quantiteParUnite; + final String? uniteMesure; + + BomLine({ + this.id, + this.composant, + required this.quantiteParUnite, + this.uniteMesure, + }); + + factory BomLine.fromJson(Map json) => BomLine( + id: json['id'], + composant: json['composant'] != null ? Article.fromJson(json['composant']) : null, + quantiteParUnite: (json['quantiteParUnite'] ?? 0).toDouble(), + uniteMesure: json['uniteMesure'], + ); +} + +class ProductionOrder { + final int? id; + final String? reference; + final Article? produitFini; + final double quantitePlanifiee; + final double quantiteRealisee; + final String datePlanifiee; + final String? dateLancement; + final String? dateTerminaison; + final String statut; + final String? notes; + + ProductionOrder({ + this.id, + this.reference, + this.produitFini, + required this.quantitePlanifiee, + this.quantiteRealisee = 0, + required this.datePlanifiee, + this.dateLancement, + this.dateTerminaison, + this.statut = 'PLANIFIE', + this.notes, + }); + + factory ProductionOrder.fromJson(Map json) => ProductionOrder( + id: json['id'], + reference: json['reference'], + produitFini: json['produitFini'] != null ? Article.fromJson(json['produitFini']) : null, + quantitePlanifiee: (json['quantitePlanifiee'] ?? 0).toDouble(), + quantiteRealisee: (json['quantiteRealisee'] ?? 0).toDouble(), + datePlanifiee: json['datePlanifiee'] ?? '', + dateLancement: json['dateLancement'], + dateTerminaison: json['dateTerminaison'], + statut: json['statut'] ?? 'PLANIFIE', + notes: json['notes'], + ); + + static const Map statutLabels = { + 'PLANIFIE': 'Planifié', + 'LANCE': 'Lancé', + 'EN_COURS': 'En cours', + 'TERMINE': 'Terminé', + 'ANNULE': 'Annulé', + }; + + static const Map statutColors = { + 'PLANIFIE': 0xFF6366F1, + 'LANCE': 0xFFF59E0B, + 'EN_COURS': 0xFF3B82F6, + 'TERMINE': 0xFF10B981, + 'ANNULE': 0xFFEF4444, + }; + + static const Map statutIcons = { + 'PLANIFIE': 0xe614, // schedule + 'LANCE': 0xe3a5, // play_arrow + 'EN_COURS': 0xe88b, // settings + 'TERMINE': 0xe876, // check_circle + 'ANNULE': 0xe5c9, // cancel + }; + + String get statutLabel => statutLabels[statut] ?? statut; + int get statutColor => statutColors[statut] ?? 0xFF6B7280; + bool get peutLancer => statut == 'PLANIFIE'; + bool get peutTerminer => statut == 'LANCE' || statut == 'EN_COURS'; +} diff --git a/frontend/lib/providers/production_provider.dart b/frontend/lib/providers/production_provider.dart new file mode 100644 index 0000000..7a845e7 --- /dev/null +++ b/frontend/lib/providers/production_provider.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import '../models/production_order.dart'; +import '../services/production_service.dart'; + +class ProductionProvider extends ChangeNotifier { + List _orders = []; + bool _isLoading = false; + String? _error; + + List get orders => _orders; + bool get isLoading => _isLoading; + String? get error => _error; + + // Statistiques rapides + int get ofPlanifies => _orders.where((o) => o.statut == 'PLANIFIE').length; + int get ofEnCours => _orders.where((o) => o.statut == 'LANCE' || o.statut == 'EN_COURS').length; + int get ofTermines => _orders.where((o) => o.statut == 'TERMINE').length; + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + _orders = await ProductionService.fetchAll(); + } catch (_) { + _error = 'Impossible de charger les ordres de fabrication.'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future plan({ + required int produitFiniId, + required double quantite, + required String datePlanifiee, + }) async { + try { + final of = await ProductionService.plan( + produitFiniId: produitFiniId, + quantite: quantite, + datePlanifiee: datePlanifiee, + ); + _orders.insert(0, of); + notifyListeners(); + return null; + } catch (e) { + final msg = e.toString(); + if (msg.contains('Stock insuffisant')) return 'Stock matières premières insuffisant'; + if (msg.contains('nomenclature')) return 'Aucune nomenclature BOM définie pour ce produit'; + return 'Erreur lors de la planification'; + } + } + + Future launch(int id) async { + try { + final updated = await ProductionService.launch(id); + _replaceOrder(updated); + return null; + } catch (_) { + return 'Erreur lors du lancement de l\'OF'; + } + } + + Future complete(int id, double quantiteRealisee) async { + try { + final updated = await ProductionService.complete(id, quantiteRealisee); + _replaceOrder(updated); + return null; + } catch (_) { + return 'Erreur lors de la clôture de l\'OF'; + } + } + + void _replaceOrder(ProductionOrder updated) { + final idx = _orders.indexWhere((o) => o.id == updated.id); + if (idx != -1) _orders[idx] = updated; + notifyListeners(); + } +} diff --git a/frontend/lib/screens/production_detail_screen.dart b/frontend/lib/screens/production_detail_screen.dart new file mode 100644 index 0000000..0d6558e --- /dev/null +++ b/frontend/lib/screens/production_detail_screen.dart @@ -0,0 +1,271 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/production_order.dart'; +import '../providers/production_provider.dart'; + +class ProductionDetailScreen extends StatelessWidget { + final ProductionOrder order; + const ProductionDetailScreen({super.key, required this.order}); + + @override + Widget build(BuildContext context) { + final color = 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)), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Statut + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all(color: color.withOpacity(0.3)), + ), + child: Row( + children: [ + Icon(Icons.circle, color: color, size: 10), + const SizedBox(width: 8), + Text(order.statutLabel, + style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 14)), + ], + ), + ), + const SizedBox(height: 16), + + // Infos + _InfoCard(children: [ + _InfoRow(label: 'Produit fini', value: order.produitFini?.designation ?? '—'), + _InfoRow(label: 'Référence article', value: order.produitFini?.reference ?? '—'), + _InfoRow(label: 'Qté planifiée', + value: '${order.quantitePlanifiee.toStringAsFixed(0)} ${order.produitFini?.uniteMesure ?? ''}'), + if (order.quantiteRealisee > 0) + _InfoRow(label: 'Qté réalisée', + value: '${order.quantiteRealisee.toStringAsFixed(0)} ${order.produitFini?.uniteMesure ?? ''}'), + _InfoRow(label: 'Date planifiée', value: order.datePlanifiee), + if (order.dateLancement != null) + _InfoRow(label: 'Date lancement', value: order.dateLancement!.substring(0, 10)), + if (order.dateTerminaison != null) + _InfoRow(label: 'Date terminaison', value: order.dateTerminaison!.substring(0, 10)), + if (order.notes != null && order.notes!.isNotEmpty) + _InfoRow(label: 'Notes', value: order.notes!), + ]), + const SizedBox(height: 24), + + // Actions + if (order.peutLancer) + _ActionCard( + title: 'Lancer la production', + description: 'Les matières premières seront consommées du stock selon la nomenclature BOM.', + buttonLabel: 'Lancer l\'OF', + buttonColor: const Color(0xFFF59E0B), + icon: Icons.play_arrow_rounded, + onConfirm: () async { + final err = await context.read().launch(order.id!); + if (context.mounted) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(err ?? 'OF lancé — matières premières consommées'), + backgroundColor: err == null ? Colors.green : Colors.red, + )); + } + }, + ), + + if (order.peutTerminer) ...[ + _CompleteCard(order: order), + ], + ], + ), + ); + } +} + +class _ActionCard extends StatelessWidget { + final String title; + final String description; + final String buttonLabel; + final Color buttonColor; + final IconData icon; + final Future Function() onConfirm; + + const _ActionCard({ + required this.title, + required this.description, + required this.buttonLabel, + required this.buttonColor, + required this.icon, + required this.onConfirm, + }); + + @override + Widget build(BuildContext context) => Container( + margin: const EdgeInsets.only(bottom: 12), + 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( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)), + const SizedBox(height: 6), + Text(description, style: TextStyle(color: Colors.grey[600], fontSize: 13)), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton.icon( + onPressed: onConfirm, + icon: Icon(icon, size: 18), + label: Text(buttonLabel, style: const TextStyle(fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: buttonColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + ); +} + +class _CompleteCard extends StatefulWidget { + final ProductionOrder order; + const _CompleteCard({required this.order}); + + @override + State<_CompleteCard> createState() => _CompleteCardState(); +} + +class _CompleteCardState extends State<_CompleteCard> { + final _qteCtrl = TextEditingController(); + bool _saving = false; + + @override + void initState() { + super.initState(); + _qteCtrl.text = widget.order.quantitePlanifiee.toStringAsFixed(0); + } + + @override + void dispose() { + _qteCtrl.dispose(); + super.dispose(); + } + + @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.05), blurRadius: 8, offset: const Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Clôturer la production', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14)), + const SizedBox(height: 6), + Text('Le produit fini sera ajouté au stock.', + style: TextStyle(color: Colors.grey[600], fontSize: 13)), + const SizedBox(height: 12), + TextFormField( + controller: _qteCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Quantité réalisée', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + filled: true, + fillColor: const Color(0xFFF8F9FA), + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ), + ), + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + height: 46, + child: ElevatedButton.icon( + onPressed: _saving ? null : _complete, + icon: const Icon(Icons.check_circle_outline, size: 18), + label: _saving + ? const SizedBox(width: 20, height: 20, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) + : const Text('Terminer l\'OF', style: TextStyle(fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF10B981), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + ), + ), + ], + ), + ); + + Future _complete() async { + final qte = double.tryParse(_qteCtrl.text) ?? 0; + if (qte <= 0) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Quantité invalide'), backgroundColor: Colors.red)); + return; + } + setState(() => _saving = true); + final err = await context.read().complete(widget.order.id!, qte); + if (mounted) { + setState(() => _saving = false); + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(err ?? 'OF terminé — produit fini ajouté au stock'), + backgroundColor: err == null ? Colors.green : Colors.red, + )); + } + } +} + +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: 140, + child: Text(label, style: TextStyle(color: Colors.grey[600], fontSize: 13))), + Expanded(child: Text(value, + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 13))), + ], + ), + ); +} diff --git a/frontend/lib/screens/production_form_screen.dart b/frontend/lib/screens/production_form_screen.dart new file mode 100644 index 0000000..33665c7 --- /dev/null +++ b/frontend/lib/screens/production_form_screen.dart @@ -0,0 +1,313 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../providers/production_provider.dart'; +import '../providers/article_provider.dart'; +import '../models/article.dart'; +import '../models/production_order.dart'; +import '../services/production_service.dart'; + +class ProductionFormScreen extends StatefulWidget { + const ProductionFormScreen({super.key}); + + @override + State createState() => _ProductionFormScreenState(); +} + +class _ProductionFormScreenState extends State { + final _formKey = GlobalKey(); + Article? _selectedProduit; + final _qteCtrl = TextEditingController(); + DateTime? _datePlanifiee; + List _bom = []; + bool _loadingBom = false; + bool _saving = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (context.read().articles.isEmpty) { + context.read().load(); + } + }); + } + + @override + void dispose() { + _qteCtrl.dispose(); + super.dispose(); + } + + List
get _produitsFinis => + context.read().articles.where((a) => a.type == 'PF').toList(); + + Future _onProduitSelected(Article? article) async { + setState(() { + _selectedProduit = article; + _bom = []; + }); + if (article?.id != null) { + setState(() => _loadingBom = true); + try { + final bom = await ProductionService.getBom(article!.id!); + setState(() => _bom = bom); + } catch (_) {} + setState(() => _loadingBom = false); + } + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + if (_selectedProduit == null) { + _showErr('Sélectionnez un produit fini'); + return; + } + if (_datePlanifiee == null) { + _showErr('Choisissez une date planifiée'); + return; + } + if (_bom.isEmpty) { + _showErr('Ce produit n\'a pas de nomenclature BOM définie'); + return; + } + + setState(() => _saving = true); + + final err = await context.read().plan( + produitFiniId: _selectedProduit!.id!, + quantite: double.tryParse(_qteCtrl.text) ?? 0, + datePlanifiee: _datePlanifiee!.toIso8601String().substring(0, 10), + ); + + if (mounted) { + setState(() => _saving = false); + if (err == null) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Ordre de fabrication planifié'), + backgroundColor: Colors.green, + )); + } else { + _showErr(err); + } + } + } + + void _showErr(String msg) => ScaffoldMessenger.of(context) + .showSnackBar(SnackBar(content: Text(msg), backgroundColor: Colors.red)); + + @override + Widget build(BuildContext context) { + final articles = context.watch(); + final qte = double.tryParse(_qteCtrl.text) ?? 0; + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text('Nouvel ordre de fabrication', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _Card( + title: 'Produit à fabriquer', + child: articles.isLoading + ? const Center(child: CircularProgressIndicator()) + : DropdownButtonFormField
( + value: _selectedProduit, + hint: const Text('Sélectionner un produit fini (PF)'), + decoration: _deco('Produit fini'), + items: _produitsFinis + .map((a) => DropdownMenuItem( + value: a, + child: Text('${a.reference} — ${a.designation}'), + )) + .toList(), + onChanged: _onProduitSelected, + validator: (v) => v == null ? 'Obligatoire' : null, + ), + ), + const SizedBox(height: 12), + + // BOM affiché après sélection + if (_loadingBom) + const Center(child: Padding( + padding: EdgeInsets.all(12), + child: CircularProgressIndicator())), + if (_bom.isNotEmpty) ...[ + _Card( + title: 'Nomenclature (BOM)', + child: Column( + children: _bom.map((b) { + final qteNecessaire = qte * b.quantiteParUnite; + final stock = b.composant?.stockActuel ?? 0; + final ok = stock >= qteNecessaire; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Row( + children: [ + Icon(ok ? Icons.check_circle_outline : Icons.warning_amber_outlined, + size: 16, color: ok ? Colors.green : Colors.orange), + const SizedBox(width: 8), + Expanded( + child: Text(b.composant?.designation ?? '—', + style: const TextStyle(fontSize: 13)), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${(b.quantiteParUnite * (qte > 0 ? qte : 1)).toStringAsFixed(2)} ${b.uniteMesure ?? ''}', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: ok ? const Color(0xFF374151) : Colors.orange), + ), + Text('Stock: ${stock.toStringAsFixed(2)}', + style: TextStyle(fontSize: 11, color: Colors.grey[500])), + ], + ), + ], + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 12), + ], + if (_selectedProduit != null && !_loadingBom && _bom.isEmpty) + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + color: Colors.orange[50], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange[200]!), + ), + child: Row( + children: [ + Icon(Icons.warning_amber, color: Colors.orange[700], size: 18), + const SizedBox(width: 8), + const Expanded( + child: Text('Aucune nomenclature BOM définie pour ce produit.', + style: TextStyle(fontSize: 13)), + ), + ], + ), + ), + + _Card( + title: 'Quantité & Date', + child: Column( + children: [ + TextFormField( + controller: _qteCtrl, + keyboardType: TextInputType.number, + decoration: _deco('Quantité à produire') + .copyWith(hintText: 'Ex: 1000'), + onChanged: (_) => setState(() {}), + validator: (v) { + if (v == null || v.isEmpty) return 'Obligatoire'; + if ((double.tryParse(v) ?? 0) <= 0) return 'Doit être > 0'; + return null; + }, + ), + const SizedBox(height: 12), + GestureDetector( + onTap: () async { + final d = await showDatePicker( + context: context, + initialDate: DateTime.now().add(const Duration(days: 1)), + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + if (d != null) setState(() => _datePlanifiee = 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( + _datePlanifiee != null + ? DateFormat('dd/MM/yyyy').format(_datePlanifiee!) + : 'Date planifiée *', + style: TextStyle( + color: _datePlanifiee != null ? Colors.black87 : Colors.grey[600]), + ), + ], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF6366F1), + 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('Planifier l\'OF', + style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)), + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + final String title; + final Widget child; + const _Card({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, + ], + ), + ); +} + +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/production_screen.dart b/frontend/lib/screens/production_screen.dart new file mode 100644 index 0000000..ddfc53f --- /dev/null +++ b/frontend/lib/screens/production_screen.dart @@ -0,0 +1,333 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/production_provider.dart'; +import '../models/production_order.dart'; +import '../widgets/app_drawer.dart'; +import 'production_form_screen.dart'; +import 'production_detail_screen.dart'; + +class ProductionScreen extends StatefulWidget { + const ProductionScreen({super.key}); + + @override + State createState() => _ProductionScreenState(); +} + +class _ProductionScreenState extends State { + String _filterStatut = 'TOUS'; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().load(); + }); + } + + static const _filters = [ + ('TOUS', 'Tous'), + ('PLANIFIE', 'Planifiés'), + ('LANCE', 'Lancés'), + ('TERMINE', 'Terminés'), + ]; + + List _filtered(List orders) { + if (_filterStatut == 'TOUS') return orders; + return orders.where((o) => o.statut == _filterStatut).toList(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final filtered = _filtered(provider.orders); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Production', style: TextStyle(fontWeight: FontWeight.bold)), + if (!provider.isLoading) + Text('${provider.ofPlanifies} planifié(s) · ${provider.ofEnCours} en cours', + style: TextStyle(fontSize: 11, color: Colors.grey[500])), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () => context.read().load(), + ), + ], + ), + drawer: const AppDrawer(currentRoute: '/production'), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => const ProductionFormScreen())), + icon: const Icon(Icons.add), + label: const Text('Nouvel OF'), + backgroundColor: const Color(0xFF6366F1), + ), + body: Column( + children: [ + // Chips filtre + Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(12, 8, 12, 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: _filters.map((f) { + final selected = _filterStatut == f.$1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(f.$2), + selected: selected, + onSelected: (_) => setState(() => _filterStatut = f.$1), + selectedColor: const Color(0xFF6366F1).withOpacity(0.15), + checkmarkColor: const Color(0xFF6366F1), + labelStyle: TextStyle( + fontSize: 12, + color: selected ? const Color(0xFF6366F1) : Colors.grey[700], + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ); + }).toList(), + ), + ), + ), + Expanded(child: _buildBody(provider, filtered)), + ], + ), + ); + } + + Widget _buildBody(ProductionProvider provider, List filtered) { + 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 (filtered.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.precision_manufacturing_outlined, size: 64, color: Colors.grey[300]), + const SizedBox(height: 16), + Text('Aucun ordre de fabrication', + style: TextStyle(color: Colors.grey[500], fontSize: 16)), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () => context.read().load(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: filtered.length, + itemBuilder: (ctx, i) => _OFCard(order: filtered[i]), + ), + ); + } +} + +class _OFCard extends StatelessWidget { + final ProductionOrder order; + const _OFCard({required this.order}); + + @override + Widget build(BuildContext context) { + final color = Color(order.statutColor); + + return GestureDetector( + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => ProductionDetailScreen(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.factory_outlined, size: 14, color: Colors.grey), + const SizedBox(width: 4), + Expanded( + child: Text(order.produitFini?.designation ?? '—', + 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.datePlanifiee, + style: TextStyle(color: Colors.grey[600], fontSize: 12)), + ], + ), + Row( + children: [ + Text( + '${order.quantitePlanifiee.toStringAsFixed(0)} ${order.produitFini?.uniteMesure ?? ''}', + style: TextStyle( + color: color, fontWeight: FontWeight.bold, fontSize: 13), + ), + if (order.quantiteRealisee > 0) ...[ + Text(' / réalisé: ${order.quantiteRealisee.toStringAsFixed(0)}', + style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + ], + ), + ], + ), + // Barre de progression si lancé + if (order.statut == 'LANCE' || order.statut == 'EN_COURS') ...[ + const SizedBox(height: 10), + LinearProgressIndicator( + value: order.quantitePlanifiee > 0 + ? (order.quantiteRealisee / order.quantitePlanifiee).clamp(0.0, 1.0) + : 0, + backgroundColor: Colors.grey[200], + color: color, + borderRadius: BorderRadius.circular(4), + ), + ], + // Actions rapides + if (order.peutLancer || order.peutTerminer) ...[ + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (order.peutLancer) + _ActionBtn( + label: 'Lancer', + icon: Icons.play_arrow_rounded, + color: const Color(0xFFF59E0B), + onTap: () => _launch(context, order), + ), + if (order.peutTerminer) + _ActionBtn( + label: 'Terminer', + icon: Icons.check_circle_outline, + color: const Color(0xFF10B981), + onTap: () => Navigator.push(context, + MaterialPageRoute( + builder: (_) => ProductionDetailScreen(order: order))), + ), + ], + ), + ], + ], + ), + ), + ), + ); + } + + void _launch(BuildContext context, ProductionOrder order) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Lancer l\'OF ?'), + content: Text( + 'Lancer ${order.reference} ?\n\nLes matières premières seront consommées du stock.'), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF59E0B)), + onPressed: () async { + Navigator.pop(context); + final err = await context.read().launch(order.id!); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(err ?? 'OF lancé — matières premières consommées'), + backgroundColor: err == null ? Colors.green : Colors.red, + )); + } + }, + child: const Text('Lancer', style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } +} + +class _ActionBtn extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final VoidCallback onTap; + const _ActionBtn({required this.label, required this.icon, required this.color, required this.onTap}); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(left: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text(label, style: TextStyle(color: color, fontWeight: FontWeight.w600, fontSize: 12)), + ], + ), + ), + ); +} diff --git a/frontend/lib/services/production_service.dart b/frontend/lib/services/production_service.dart new file mode 100644 index 0000000..f03a550 --- /dev/null +++ b/frontend/lib/services/production_service.dart @@ -0,0 +1,39 @@ +import 'api_client.dart'; +import '../models/production_order.dart'; + +class ProductionService { + static Future> fetchAll() async { + final res = await ApiClient.instance.get('/production/orders'); + return (res.data as List).map((e) => ProductionOrder.fromJson(e)).toList(); + } + + static Future> getBom(int produitFiniId) async { + final res = await ApiClient.instance.get('/production/bom/$produitFiniId'); + return (res.data as List).map((e) => BomLine.fromJson(e)).toList(); + } + + static Future plan({ + required int produitFiniId, + required double quantite, + required String datePlanifiee, + }) async { + final res = await ApiClient.instance.post('/production/orders/plan', data: { + 'produitFiniId': produitFiniId, + 'quantite': quantite, + 'datePlanifiee': datePlanifiee, + }); + return ProductionOrder.fromJson(res.data); + } + + static Future launch(int id) async { + final res = await ApiClient.instance.post('/production/orders/$id/launch'); + return ProductionOrder.fromJson(res.data); + } + + static Future complete(int id, double quantiteRealisee) async { + final res = await ApiClient.instance.post('/production/orders/$id/complete', data: { + 'quantiteRealisee': quantiteRealisee, + }); + return ProductionOrder.fromJson(res.data); + } +}