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/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')),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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