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:
parent
0811013abe
commit
acbf3a1600
|
|
@ -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')),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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),
|
||||
);
|
||||
|
|
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue