diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index 4b3bf04..f13fc2a 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -8,12 +8,14 @@ import 'providers/article_provider.dart'; import 'providers/ventes_provider.dart'; import 'providers/achats_provider.dart'; import 'providers/production_provider.dart'; +import 'providers/stock_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 'screens/stock_screen.dart'; import 'widgets/app_drawer.dart'; void main() { @@ -26,6 +28,7 @@ void main() { ChangeNotifierProvider(create: (_) => VentesProvider()), ChangeNotifierProvider(create: (_) => AchatsProvider()), ChangeNotifierProvider(create: (_) => ProductionProvider()), + ChangeNotifierProvider(create: (_) => StockProvider()), ], child: const RayhanApp(), ), @@ -55,7 +58,7 @@ class RayhanApp extends StatelessWidget { GoRoute(path: '/ventes', builder: (_, __) => const VentesScreen()), GoRoute(path: '/achats', builder: (_, __) => const AchatsScreen()), GoRoute(path: '/production', builder: (_, __) => const ProductionScreen()), - GoRoute(path: '/stock', builder: (_, __) => const _PlaceholderScreen(title: 'Stock', route: '/stock')), + GoRoute(path: '/stock', builder: (_, __) => const StockScreen()), ], ); diff --git a/frontend/lib/models/stock_movement.dart b/frontend/lib/models/stock_movement.dart new file mode 100644 index 0000000..c71eda5 --- /dev/null +++ b/frontend/lib/models/stock_movement.dart @@ -0,0 +1,61 @@ +import 'article.dart'; + +class StockMovement { + final int? id; + final Article? article; + final String type; // IN ou OUT + final double quantite; + final double? stockAvant; + final double? stockApres; + final String? sourceDocument; + final String? referenceDocument; + final String? motif; + final String dateHeure; + + StockMovement({ + this.id, + this.article, + required this.type, + required this.quantite, + this.stockAvant, + this.stockApres, + this.sourceDocument, + this.referenceDocument, + this.motif, + required this.dateHeure, + }); + + factory StockMovement.fromJson(Map json) => StockMovement( + id: json['id'], + article: json['article'] != null ? Article.fromJson(json['article']) : null, + type: json['type'] ?? 'IN', + quantite: (json['quantite'] ?? 0).toDouble(), + stockAvant: json['stockAvant']?.toDouble(), + stockApres: json['stockApres']?.toDouble(), + sourceDocument: json['sourceDocument'], + referenceDocument: json['referenceDocument'], + motif: json['motif'], + dateHeure: json['dateHeure'] ?? '', + ); + + bool get isEntree => type == 'IN'; + + String get dateFormatted { + if (dateHeure.length >= 10) return dateHeure.substring(0, 10); + return dateHeure; + } + + String get heureFormatted { + if (dateHeure.length >= 16) return dateHeure.substring(11, 16); + return ''; + } + + static const Map sourceLabels = { + 'BON_RECEPTION': 'Bon de réception', + 'BON_LIVRAISON': 'Bon de livraison', + 'ORDRE_FABRICATION': 'Ordre de fabrication', + 'AJUSTEMENT': 'Ajustement manuel', + }; + + String get sourceLabel => sourceLabels[sourceDocument] ?? (sourceDocument ?? '—'); +} diff --git a/frontend/lib/providers/stock_provider.dart b/frontend/lib/providers/stock_provider.dart new file mode 100644 index 0000000..6cc7a27 --- /dev/null +++ b/frontend/lib/providers/stock_provider.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import '../models/stock_movement.dart'; +import '../services/stock_service.dart'; + +class StockProvider extends ChangeNotifier { + final Map> _historiques = {}; + bool _isLoading = false; + String? _error; + + bool get isLoading => _isLoading; + String? get error => _error; + + List historiqueOf(int articleId) => _historiques[articleId] ?? []; + + Future loadHistorique(int articleId) async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + _historiques[articleId] = await StockService.getHistorique(articleId); + } catch (_) { + _error = 'Impossible de charger l\'historique.'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future adjust({ + required int articleId, + required double quantite, + required String type, + required String motif, + }) async { + try { + final mouvement = await StockService.adjust( + articleId: articleId, + quantite: quantite, + type: type, + motif: motif, + ); + _historiques[articleId] = [mouvement, ...(_historiques[articleId] ?? [])]; + notifyListeners(); + return null; + } catch (_) { + return 'Erreur lors de l\'ajustement du stock'; + } + } +} diff --git a/frontend/lib/screens/stock_detail_screen.dart b/frontend/lib/screens/stock_detail_screen.dart new file mode 100644 index 0000000..6cca894 --- /dev/null +++ b/frontend/lib/screens/stock_detail_screen.dart @@ -0,0 +1,377 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../models/article.dart'; +import '../models/stock_movement.dart'; +import '../providers/stock_provider.dart'; +import '../providers/article_provider.dart'; + +class StockDetailScreen extends StatefulWidget { + final Article article; + const StockDetailScreen({super.key, required this.article}); + + @override + State createState() => _StockDetailScreenState(); +} + +class _StockDetailScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().loadHistorique(widget.article.id!); + }); + } + + @override + Widget build(BuildContext context) { + final stockProvider = context.watch(); + final historique = stockProvider.historiqueOf(widget.article.id!); + final article = widget.article; + final priceFmt = NumberFormat.currency(locale: 'fr_TN', symbol: 'TND', decimalDigits: 3); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Text(article.reference, style: const TextStyle(fontWeight: FontWeight.bold)), + actions: [ + IconButton( + icon: const Icon(Icons.tune_outlined), + tooltip: 'Ajustement manuel', + onPressed: () => _showAdjustDialog(context), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Carte résumé article + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + colors: article.enAlerte + ? [Colors.red[600]!, Colors.red[400]!] + : [const Color(0xFF1565C0), const Color(0xFF1976D2)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(article.designation, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 16)), + const SizedBox(height: 4), + Text('${article.type} · ${article.uniteMesure ?? ''}', + style: const TextStyle(color: Colors.white70, fontSize: 12)), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _StatCol(label: 'Stock actuel', value: article.stockActuel.toStringAsFixed(2)), + _StatCol(label: 'Seuil minimum', value: article.stockMinimum.toStringAsFixed(2)), + _StatCol(label: 'Prix unitaire', value: priceFmt.format(article.prixUnitaire)), + ], + ), + if (article.enAlerte) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(8), + ), + child: const Row( + children: [ + Icon(Icons.warning_amber, color: Colors.white, size: 16), + SizedBox(width: 8), + Text('Stock inférieur au seuil minimum — réapprovisionner', + style: TextStyle(color: Colors.white, fontSize: 12)), + ], + ), + ), + ], + ], + ), + ), + const SizedBox(height: 20), + + // Historique + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('Historique des mouvements', + style: TextStyle(fontWeight: FontWeight.w700, fontSize: 14)), + if (stockProvider.isLoading) + const SizedBox( + width: 16, height: 16, + child: CircularProgressIndicator(strokeWidth: 2)), + ], + ), + const SizedBox(height: 10), + + if (historique.isEmpty && !stockProvider.isLoading) + Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Center( + child: Text('Aucun mouvement enregistré', + style: TextStyle(color: Colors.grey[500])), + ), + ), + + ...historique.map((m) => _MovementCard(movement: m)), + const SizedBox(height: 32), + ], + ), + ); + } + + void _showAdjustDialog(BuildContext context) { + final qteCtrl = TextEditingController(); + final motifCtrl = TextEditingController(); + String type = 'IN'; + + showDialog( + context: context, + builder: (ctx) => StatefulBuilder( + builder: (ctx, setDlgState) => AlertDialog( + title: const Text('Ajustement de stock'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(widget.article.designation, + style: const TextStyle(fontWeight: FontWeight.w600)), + Text('Stock actuel : ${widget.article.stockActuel}', + style: TextStyle(color: Colors.grey[600], fontSize: 13)), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: _TypeBtn( + label: 'Entrée', + icon: Icons.add_circle_outline, + color: Colors.green, + selected: type == 'IN', + onTap: () => setDlgState(() => type = 'IN'), + ), + ), + const SizedBox(width: 8), + Expanded( + child: _TypeBtn( + label: 'Sortie', + icon: Icons.remove_circle_outline, + color: Colors.red, + selected: type == 'OUT', + onTap: () => setDlgState(() => type = 'OUT'), + ), + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: qteCtrl, + keyboardType: TextInputType.number, + decoration: InputDecoration( + labelText: 'Quantité', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + filled: true, + fillColor: const Color(0xFFF8F9FA), + ), + ), + const SizedBox(height: 12), + TextField( + controller: motifCtrl, + decoration: InputDecoration( + labelText: 'Motif', + hintText: 'Inventaire, correction, perte…', + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + filled: true, + fillColor: const Color(0xFFF8F9FA), + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx), + child: const Text('Annuler'), + ), + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: type == 'IN' ? Colors.green : Colors.red, + foregroundColor: Colors.white, + ), + onPressed: () async { + final qte = double.tryParse(qteCtrl.text) ?? 0; + if (qte <= 0) return; + Navigator.pop(ctx); + final err = await context.read().adjust( + articleId: widget.article.id!, + quantite: qte, + type: type, + motif: motifCtrl.text.trim().isEmpty + ? 'Ajustement manuel' + : motifCtrl.text.trim(), + ); + // Recharger l'article pour mettre à jour le stock affiché + if (context.mounted) { + context.read().load(); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(err ?? 'Stock ajusté avec succès'), + backgroundColor: err == null ? Colors.green : Colors.red, + )); + } + }, + child: const Text('Confirmer'), + ), + ], + ), + ), + ); + } +} + +class _StatCol extends StatelessWidget { + final String label; + final String value; + const _StatCol({required this.label, required this.value}); + + @override + Widget build(BuildContext context) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(color: Colors.white60, fontSize: 11)), + const SizedBox(height: 2), + Text(value, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 15)), + ], + ); +} + +class _MovementCard extends StatelessWidget { + final StockMovement movement; + const _MovementCard({required this.movement}); + + @override + Widget build(BuildContext context) { + final isIn = movement.isEntree; + final color = isIn ? Colors.green : Colors.red; + + return Container( + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 6, offset: const Offset(0, 1)), + ], + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon( + isIn ? Icons.arrow_downward_rounded : Icons.arrow_upward_rounded, + color: color, + size: 20, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + isIn ? '+${movement.quantite}' : '-${movement.quantite}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + color: color), + ), + Text(movement.dateFormatted, + style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + ), + const SizedBox(height: 2), + Text(movement.sourceLabel, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), + if (movement.referenceDocument != null && + movement.referenceDocument != 'ADJ') ...[ + Text(movement.referenceDocument!, + style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + if (movement.motif != null) ...[ + Text(movement.motif!, + style: TextStyle(color: Colors.grey[500], fontSize: 11)), + ], + if (movement.stockApres != null) ...[ + const SizedBox(height: 2), + Text( + 'Stock après : ${movement.stockApres!.toStringAsFixed(2)}', + style: TextStyle(color: Colors.grey[600], fontSize: 11), + ), + ], + ], + ), + ), + ], + ), + ), + ); + } +} + +class _TypeBtn extends StatelessWidget { + final String label; + final IconData icon; + final Color color; + final bool selected; + final VoidCallback onTap; + const _TypeBtn({ + required this.label, required this.icon, required this.color, + required this.selected, required this.onTap, + }); + + @override + Widget build(BuildContext context) => GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10), + decoration: BoxDecoration( + color: selected ? color.withOpacity(0.12) : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: selected ? color : Colors.grey[300]!, width: selected ? 1.5 : 1), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, color: selected ? color : Colors.grey, size: 18), + const SizedBox(width: 6), + Text(label, + style: TextStyle( + color: selected ? color : Colors.grey[600], + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + fontSize: 13)), + ], + ), + ), + ); +} diff --git a/frontend/lib/screens/stock_screen.dart b/frontend/lib/screens/stock_screen.dart new file mode 100644 index 0000000..f96666a --- /dev/null +++ b/frontend/lib/screens/stock_screen.dart @@ -0,0 +1,322 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../providers/article_provider.dart'; +import '../models/article.dart'; +import '../widgets/app_drawer.dart'; +import 'stock_detail_screen.dart'; + +class StockScreen extends StatefulWidget { + const StockScreen({super.key}); + + @override + State createState() => _StockScreenState(); +} + +class _StockScreenState extends State { + String _filter = 'TOUS'; + final _searchCtrl = TextEditingController(); + String _search = ''; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().load(); + }); + } + + @override + void dispose() { + _searchCtrl.dispose(); + super.dispose(); + } + + List
_filtered(List
articles) { + return articles.where((a) { + final matchType = _filter == 'TOUS' || a.type == _filter; + final matchAlerte = _filter == 'ALERTE' ? a.enAlerte : true; + final matchSearch = _search.isEmpty || + a.reference.toLowerCase().contains(_search.toLowerCase()) || + a.designation.toLowerCase().contains(_search.toLowerCase()); + return (matchType || matchAlerte) && matchSearch; + }).toList() + ..sort((a, b) { + if (a.enAlerte && !b.enAlerte) return -1; + if (!a.enAlerte && b.enAlerte) return 1; + return a.designation.compareTo(b.designation); + }); + } + + static const _filters = [ + ('TOUS', 'Tous'), + ('ALERTE', '🔴 Alertes'), + ('MP', 'MP'), + ('PSF', 'PSF'), + ('PF', 'PF'), + ]; + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + final articles = _filtered(provider.articles); + final alertCount = provider.articles.where((a) => a.enAlerte).length; + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('Stock', style: TextStyle(fontWeight: FontWeight.bold)), + if (!provider.isLoading) + Text('${provider.articles.length} articles · $alertCount en alerte', + style: TextStyle( + fontSize: 11, + color: alertCount > 0 ? Colors.red[600] : Colors.grey[500])), + ], + ), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () => context.read().load(), + ), + ], + ), + drawer: const AppDrawer(currentRoute: '/stock'), + body: Column( + children: [ + // Barre de recherche + Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 0), + child: TextField( + controller: _searchCtrl, + onChanged: (v) => setState(() => _search = v), + decoration: InputDecoration( + hintText: 'Rechercher un article…', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: _search.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () => setState(() { + _searchCtrl.clear(); + _search = ''; + }), + ) + : null, + filled: true, + fillColor: const Color(0xFFF5F7FA), + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + ), + ), + // Filtres + 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 = _filter == f.$1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(f.$2), + selected: selected, + onSelected: (_) => setState(() => _filter = f.$1), + selectedColor: f.$1 == 'ALERTE' + ? Colors.red.withOpacity(0.15) + : Theme.of(context).colorScheme.primary.withOpacity(0.15), + checkmarkColor: f.$1 == 'ALERTE' + ? Colors.red + : Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + fontSize: 12, + color: selected + ? (f.$1 == 'ALERTE' + ? Colors.red + : Theme.of(context).colorScheme.primary) + : Colors.grey[700], + fontWeight: selected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ); + }).toList(), + ), + ), + ), + // Liste + Expanded(child: _buildList(provider, articles)), + ], + ), + ); + } + + Widget _buildList(ArticleProvider provider, List
articles) { + if (provider.isLoading) return const Center(child: CircularProgressIndicator()); + if (provider.error != null) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.grey[400]), + const SizedBox(height: 12), + Text(provider.error!), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.read().load(), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + if (articles.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warehouse_outlined, size: 64, color: Colors.grey[300]), + const SizedBox(height: 16), + Text('Aucun article', style: TextStyle(color: Colors.grey[500], fontSize: 16)), + ], + ), + ); + } + return RefreshIndicator( + onRefresh: () => context.read().load(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: articles.length, + itemBuilder: (ctx, i) => _StockCard(article: articles[i]), + ), + ); + } +} + +class _StockCard extends StatelessWidget { + final Article article; + const _StockCard({required this.article}); + + static const typeColors = { + 'MP': Color(0xFF3B82F6), + 'PSF': Color(0xFF8B5CF6), + 'PF': Color(0xFF10B981), + }; + + @override + Widget build(BuildContext context) { + final typeColor = typeColors[article.type] ?? Colors.grey; + final pct = article.stockMinimum > 0 + ? (article.stockActuel / article.stockMinimum).clamp(0.0, 2.0) + : 1.0; + final barColor = article.enAlerte ? Colors.red : Colors.green; + + return GestureDetector( + onTap: () => Navigator.push(context, + MaterialPageRoute(builder: (_) => StockDetailScreen(article: article))), + child: Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: article.enAlerte ? Border.all(color: Colors.red[300]!, width: 1.5) : null, + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 8, offset: const Offset(0, 2)), + ], + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: typeColor.withOpacity(0.12), + borderRadius: BorderRadius.circular(6), + ), + child: Text(article.type, + style: TextStyle(color: typeColor, fontSize: 10, fontWeight: FontWeight.bold)), + ), + const SizedBox(width: 8), + Expanded( + child: Text(article.designation, + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), + ), + if (article.enAlerte) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: Colors.red[50], + borderRadius: BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.warning_amber, size: 12, color: Colors.red[600]), + const SizedBox(width: 3), + Text('ALERTE', style: TextStyle(color: Colors.red[600], fontSize: 10, fontWeight: FontWeight.bold)), + ], + ), + ), + ], + ), + const SizedBox(height: 8), + Text(article.reference, style: TextStyle(color: Colors.grey[500], fontSize: 11)), + const SizedBox(height: 10), + // Barre de stock + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Stock : ${article.stockActuel.toStringAsFixed(2)} ${article.uniteMesure ?? ''}', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: article.enAlerte ? Colors.red[700] : const Color(0xFF374151)), + ), + Text( + 'Min : ${article.stockMinimum.toStringAsFixed(2)}', + style: TextStyle(fontSize: 11, color: Colors.grey[500]), + ), + ], + ), + const SizedBox(height: 6), + ClipRRect( + borderRadius: BorderRadius.circular(4), + child: LinearProgressIndicator( + value: (pct / 2).clamp(0.0, 1.0), + backgroundColor: Colors.grey[200], + color: barColor, + minHeight: 6, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Icon(Icons.chevron_right, color: Colors.grey[400]), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/frontend/lib/services/stock_service.dart b/frontend/lib/services/stock_service.dart new file mode 100644 index 0000000..bf2d96f --- /dev/null +++ b/frontend/lib/services/stock_service.dart @@ -0,0 +1,24 @@ +import 'api_client.dart'; +import '../models/stock_movement.dart'; + +class StockService { + static Future> getHistorique(int articleId) async { + final res = await ApiClient.instance.get('/stock/historique/$articleId'); + return (res.data as List).map((e) => StockMovement.fromJson(e)).toList(); + } + + static Future adjust({ + required int articleId, + required double quantite, + required String type, // IN ou OUT + required String motif, + }) async { + final res = await ApiClient.instance.post('/stock/adjust', data: { + 'articleId': articleId, + 'quantite': quantite, + 'type': type, + 'motif': motif, + }); + return StockMovement.fromJson(res.data); + } +}