feat(frontend): module Production complet (BOM, OF, cycle PLANIFIE→LANCE→TERMINE)

- 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 <noreply@anthropic.com>
This commit is contained in:
Nabil Derouiche 2026-04-20 20:39:05 +01:00
parent 0811013abe
commit acbf3a1600
7 changed files with 1130 additions and 1 deletions

View File

@ -7,11 +7,13 @@ import 'providers/dashboard_provider.dart';
import 'providers/article_provider.dart'; import 'providers/article_provider.dart';
import 'providers/ventes_provider.dart'; import 'providers/ventes_provider.dart';
import 'providers/achats_provider.dart'; import 'providers/achats_provider.dart';
import 'providers/production_provider.dart';
import 'screens/login_screen.dart'; import 'screens/login_screen.dart';
import 'screens/dashboard_screen.dart'; import 'screens/dashboard_screen.dart';
import 'screens/articles_screen.dart'; import 'screens/articles_screen.dart';
import 'screens/ventes_screen.dart'; import 'screens/ventes_screen.dart';
import 'screens/achats_screen.dart'; import 'screens/achats_screen.dart';
import 'screens/production_screen.dart';
import 'widgets/app_drawer.dart'; import 'widgets/app_drawer.dart';
void main() { void main() {
@ -23,6 +25,7 @@ void main() {
ChangeNotifierProvider(create: (_) => ArticleProvider()), ChangeNotifierProvider(create: (_) => ArticleProvider()),
ChangeNotifierProvider(create: (_) => VentesProvider()), ChangeNotifierProvider(create: (_) => VentesProvider()),
ChangeNotifierProvider(create: (_) => AchatsProvider()), ChangeNotifierProvider(create: (_) => AchatsProvider()),
ChangeNotifierProvider(create: (_) => ProductionProvider()),
], ],
child: const RayhanApp(), child: const RayhanApp(),
), ),
@ -51,7 +54,7 @@ class RayhanApp extends StatelessWidget {
GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()), GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()),
GoRoute(path: '/ventes', builder: (_, __) => const VentesScreen()), GoRoute(path: '/ventes', builder: (_, __) => const VentesScreen()),
GoRoute(path: '/achats', builder: (_, __) => const AchatsScreen()), 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')), GoRoute(path: '/stock', builder: (_, __) => const _PlaceholderScreen(title: 'Stock', route: '/stock')),
], ],
); );

View File

@ -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<String, dynamic> 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<String, dynamic> 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<String, String> statutLabels = {
'PLANIFIE': 'Planifié',
'LANCE': 'Lancé',
'EN_COURS': 'En cours',
'TERMINE': 'Terminé',
'ANNULE': 'Annulé',
};
static const Map<String, int> statutColors = {
'PLANIFIE': 0xFF6366F1,
'LANCE': 0xFFF59E0B,
'EN_COURS': 0xFF3B82F6,
'TERMINE': 0xFF10B981,
'ANNULE': 0xFFEF4444,
};
static const Map<String, int> 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';
}

View File

@ -0,0 +1,80 @@
import 'package:flutter/material.dart';
import '../models/production_order.dart';
import '../services/production_service.dart';
class ProductionProvider extends ChangeNotifier {
List<ProductionOrder> _orders = [];
bool _isLoading = false;
String? _error;
List<ProductionOrder> 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<void> 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<String?> 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<String?> 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<String?> 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();
}
}

View File

@ -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<ProductionProvider>().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<void> 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<void> _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<ProductionProvider>().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<Widget> 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))),
],
),
);
}

View File

@ -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<ProductionFormScreen> createState() => _ProductionFormScreenState();
}
class _ProductionFormScreenState extends State<ProductionFormScreen> {
final _formKey = GlobalKey<FormState>();
Article? _selectedProduit;
final _qteCtrl = TextEditingController();
DateTime? _datePlanifiee;
List<BomLine> _bom = [];
bool _loadingBom = false;
bool _saving = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.read<ArticleProvider>().articles.isEmpty) {
context.read<ArticleProvider>().load();
}
});
}
@override
void dispose() {
_qteCtrl.dispose();
super.dispose();
}
List<Article> get _produitsFinis =>
context.read<ArticleProvider>().articles.where((a) => a.type == 'PF').toList();
Future<void> _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<void> _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<ProductionProvider>().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<ArticleProvider>();
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<Article>(
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),
);

View File

@ -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<ProductionScreen> createState() => _ProductionScreenState();
}
class _ProductionScreenState extends State<ProductionScreen> {
String _filterStatut = 'TOUS';
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<ProductionProvider>().load();
});
}
static const _filters = [
('TOUS', 'Tous'),
('PLANIFIE', 'Planifiés'),
('LANCE', 'Lancés'),
('TERMINE', 'Terminés'),
];
List<ProductionOrder> _filtered(List<ProductionOrder> orders) {
if (_filterStatut == 'TOUS') return orders;
return orders.where((o) => o.statut == _filterStatut).toList();
}
@override
Widget build(BuildContext context) {
final provider = context.watch<ProductionProvider>();
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<ProductionProvider>().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<ProductionOrder> 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<ProductionProvider>().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<ProductionProvider>().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<ProductionProvider>().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)),
],
),
),
);
}

View File

@ -0,0 +1,39 @@
import 'api_client.dart';
import '../models/production_order.dart';
class ProductionService {
static Future<List<ProductionOrder>> fetchAll() async {
final res = await ApiClient.instance.get('/production/orders');
return (res.data as List).map((e) => ProductionOrder.fromJson(e)).toList();
}
static Future<List<BomLine>> 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<ProductionOrder> 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<ProductionOrder> launch(int id) async {
final res = await ApiClient.instance.post('/production/orders/$id/launch');
return ProductionOrder.fromJson(res.data);
}
static Future<ProductionOrder> complete(int id, double quantiteRealisee) async {
final res = await ApiClient.instance.post('/production/orders/$id/complete', data: {
'quantiteRealisee': quantiteRealisee,
});
return ProductionOrder.fromJson(res.data);
}
}