diff --git a/Livrables/rapport-projet.md b/Livrables/rapport-projet.md index c663aa4..7265541 100644 --- a/Livrables/rapport-projet.md +++ b/Livrables/rapport-projet.md @@ -555,3 +555,63 @@ Le drawer affiche le rôle de l'utilisateur connecté et met en surbrillance la --- *[Section à compléter avec captures d'écran lors de la finalisation du rapport]* + +--- + +## 5.6 Module Articles + +### Objectif + +Le module Articles permet de gérer le **catalogue complet** des références de l'entreprise, organisé en trois catégories correspondant au cycle industriel de SUARL Rayhan : + +| Type | Code | Description | +|------|------|-------------| +| Matière Première | MP | Granulés PEHD/LDPE, colorants, additifs | +| Produit Semi-Fini | PSF | Film tubulaire non découpé | +| Produit Fini | PF | Sacs Bertel, sacs poubelles, film rétractable | + +### Fonctionnalités + +- **Liste filtrée** : affichage de tous les articles avec filtre par type (MP / PSF / PF) et barre de recherche en temps réel +- **Indicateur d'alerte** : les articles dont le stock est inférieur au seuil minimum sont signalés en rouge avec icône d'avertissement +- **Création** : formulaire complet (référence, désignation, type, unité de mesure, prix unitaire, stock initial, seuil d'alerte) +- **Modification** : édition de tous les champs sauf la référence (clé métier immuable) +- **Archivage** : suppression logique (l'article est marqué `actif=false`, pas effacé de la base) + +### Architecture technique + +``` +ArticleService (Dio) ArticleProvider (ChangeNotifier) + ├── fetchAll() ─────► ├── _articles: List
+ ├── fetchByType() ├── _filterType: String + ├── create() ├── _search: String + ├── update() └── articles (getter filtré) + └── delete() + │ + ArticlesScreen + ├── _SearchBar + ├── _FilterChips (TOUS / MP / PSF / PF) + └── ListView → _ArticleCard + └── PopupMenu (Modifier / Supprimer) + + ArticleFormScreen (création + édition) + ├── Section Identification (ref, désignation, type) + ├── Section Unité & Prix + └── Section Stock (actuel + seuil minimum) +``` + +### Modèle de données + +```dart +class Article { + int? id; + String reference; // Clé unique (ex: MP-001) + String designation; // Libellé + String type; // MP | PSF | PF + String? uniteMesure; // kg, unité, rouleau, m + double prixUnitaire; // En TND + double stockActuel; // Quantité en stock + double stockMinimum; // Seuil d'alerte + bool actif; // Soft delete +} +``` diff --git a/frontend/lib/main.dart b/frontend/lib/main.dart index e3d4e8d..4e3fc63 100644 --- a/frontend/lib/main.dart +++ b/frontend/lib/main.dart @@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart'; import 'providers/auth_provider.dart'; import 'providers/dashboard_provider.dart'; +import 'providers/article_provider.dart'; import 'screens/login_screen.dart'; import 'screens/dashboard_screen.dart'; +import 'screens/articles_screen.dart'; import 'widgets/app_drawer.dart'; void main() { @@ -14,6 +16,7 @@ void main() { providers: [ ChangeNotifierProvider(create: (_) => AuthProvider()), ChangeNotifierProvider(create: (_) => DashboardProvider()), + ChangeNotifierProvider(create: (_) => ArticleProvider()), ], child: const RayhanApp(), ), @@ -39,7 +42,7 @@ class RayhanApp extends StatelessWidget { routes: [ GoRoute(path: '/login', builder: (_, __) => const LoginScreen()), GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()), - GoRoute(path: '/articles', builder: (_, __) => const _PlaceholderScreen(title: 'Articles', route: '/articles')), + GoRoute(path: '/articles', builder: (_, __) => const ArticlesScreen()), GoRoute(path: '/ventes', builder: (_, __) => const _PlaceholderScreen(title: 'Ventes', route: '/ventes')), GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')), GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')), diff --git a/frontend/lib/models/article.dart b/frontend/lib/models/article.dart new file mode 100644 index 0000000..a60fbfd --- /dev/null +++ b/frontend/lib/models/article.dart @@ -0,0 +1,57 @@ +class Article { + final int? id; + final String reference; + final String designation; + final String type; // MP, PSF, PF + final String? uniteMesure; + final double prixUnitaire; + final double stockActuel; + final double stockMinimum; + final bool actif; + + Article({ + this.id, + required this.reference, + required this.designation, + required this.type, + this.uniteMesure, + this.prixUnitaire = 0, + this.stockActuel = 0, + this.stockMinimum = 0, + this.actif = true, + }); + + factory Article.fromJson(Map json) => Article( + id: json['id'], + reference: json['reference'] ?? '', + designation: json['designation'] ?? '', + type: json['type'] ?? 'MP', + uniteMesure: json['uniteMesure'], + prixUnitaire: (json['prixUnitaire'] ?? 0).toDouble(), + stockActuel: (json['stockActuel'] ?? 0).toDouble(), + stockMinimum: (json['stockMinimum'] ?? 0).toDouble(), + actif: json['actif'] ?? true, + ); + + Map toJson() => { + if (id != null) 'id': id, + 'reference': reference, + 'designation': designation, + 'type': type, + 'uniteMesure': uniteMesure, + 'prixUnitaire': prixUnitaire, + 'stockActuel': stockActuel, + 'stockMinimum': stockMinimum, + 'actif': actif, + }; + + bool get enAlerte => stockActuel <= stockMinimum; + + static const Map typeLabels = { + 'MP': 'Matière Première', + 'PSF': 'Produit Semi-Fini', + 'PF': 'Produit Fini', + }; + + String get typeLabel => typeLabels[type] ?? type; +} diff --git a/frontend/lib/providers/article_provider.dart b/frontend/lib/providers/article_provider.dart new file mode 100644 index 0000000..5cd6eb5 --- /dev/null +++ b/frontend/lib/providers/article_provider.dart @@ -0,0 +1,84 @@ +import 'package:flutter/material.dart'; +import '../models/article.dart'; +import '../services/article_service.dart'; + +class ArticleProvider extends ChangeNotifier { + List
_articles = []; + bool _isLoading = false; + String? _error; + String _filterType = 'TOUS'; + String _search = ''; + + List
get articles => _filtered(); + bool get isLoading => _isLoading; + String? get error => _error; + String get filterType => _filterType; + + List
_filtered() { + return _articles.where((a) { + final matchType = _filterType == 'TOUS' || a.type == _filterType; + final matchSearch = _search.isEmpty || + a.reference.toLowerCase().contains(_search.toLowerCase()) || + a.designation.toLowerCase().contains(_search.toLowerCase()); + return matchType && matchSearch; + }).toList(); + } + + void setFilter(String type) { + _filterType = type; + notifyListeners(); + } + + void setSearch(String q) { + _search = q; + notifyListeners(); + } + + Future load() async { + _isLoading = true; + _error = null; + notifyListeners(); + try { + _articles = await ArticleService.fetchAll(); + } catch (_) { + _error = 'Impossible de charger les articles.'; + } finally { + _isLoading = false; + notifyListeners(); + } + } + + Future create(Article article) async { + try { + final created = await ArticleService.create(article); + _articles.add(created); + notifyListeners(); + return true; + } catch (_) { + return false; + } + } + + Future update(int id, Article article) async { + try { + final updated = await ArticleService.update(id, article); + final idx = _articles.indexWhere((a) => a.id == id); + if (idx != -1) _articles[idx] = updated; + notifyListeners(); + return true; + } catch (_) { + return false; + } + } + + Future delete(int id) async { + try { + await ArticleService.delete(id); + _articles.removeWhere((a) => a.id == id); + notifyListeners(); + return true; + } catch (_) { + return false; + } + } +} diff --git a/frontend/lib/screens/article_form_screen.dart b/frontend/lib/screens/article_form_screen.dart new file mode 100644 index 0000000..abdd30a --- /dev/null +++ b/frontend/lib/screens/article_form_screen.dart @@ -0,0 +1,299 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import '../models/article.dart'; +import '../providers/article_provider.dart'; + +class ArticleFormScreen extends StatefulWidget { + final Article? article; + const ArticleFormScreen({super.key, this.article}); + + @override + State createState() => _ArticleFormScreenState(); +} + +class _ArticleFormScreenState extends State { + final _formKey = GlobalKey(); + late final TextEditingController _refCtrl; + late final TextEditingController _desCtrl; + late final TextEditingController _uniteCtrl; + late final TextEditingController _prixCtrl; + late final TextEditingController _stockMinCtrl; + late final TextEditingController _stockActuelCtrl; + late String _type; + bool _saving = false; + + bool get _isEdit => widget.article != null; + + @override + void initState() { + super.initState(); + final a = widget.article; + _refCtrl = TextEditingController(text: a?.reference ?? ''); + _desCtrl = TextEditingController(text: a?.designation ?? ''); + _uniteCtrl = TextEditingController(text: a?.uniteMesure ?? ''); + _prixCtrl = TextEditingController( + text: a != null ? a.prixUnitaire.toString() : ''); + _stockMinCtrl = TextEditingController( + text: a != null ? a.stockMinimum.toString() : ''); + _stockActuelCtrl = TextEditingController( + text: a != null ? a.stockActuel.toString() : '0'); + _type = a?.type ?? 'MP'; + } + + @override + void dispose() { + _refCtrl.dispose(); + _desCtrl.dispose(); + _uniteCtrl.dispose(); + _prixCtrl.dispose(); + _stockMinCtrl.dispose(); + _stockActuelCtrl.dispose(); + super.dispose(); + } + + Future _save() async { + if (!_formKey.currentState!.validate()) return; + setState(() => _saving = true); + + final article = Article( + id: widget.article?.id, + reference: _refCtrl.text.trim(), + designation: _desCtrl.text.trim(), + type: _type, + uniteMesure: _uniteCtrl.text.trim().isEmpty ? null : _uniteCtrl.text.trim(), + prixUnitaire: double.tryParse(_prixCtrl.text) ?? 0, + stockMinimum: double.tryParse(_stockMinCtrl.text) ?? 0, + stockActuel: double.tryParse(_stockActuelCtrl.text) ?? 0, + ); + + final provider = context.read(); + final ok = _isEdit + ? await provider.update(article.id!, article) + : await provider.create(article); + + if (mounted) { + setState(() => _saving = false); + if (ok) { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(_isEdit ? 'Article modifié' : 'Article créé'), + backgroundColor: Colors.green, + )); + } else { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Erreur — vérifiez les champs (référence déjà utilisée ?)'), + backgroundColor: Colors.red, + )); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: Text(_isEdit ? 'Modifier l\'article' : 'Nouvel article', + style: const TextStyle(fontWeight: FontWeight.bold)), + ), + body: SingleChildScrollView( + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _Card( + title: 'Identification', + children: [ + _Field( + label: 'Référence', + controller: _refCtrl, + hint: 'Ex: MP-001', + enabled: !_isEdit, + validator: (v) => v == null || v.trim().isEmpty + ? 'Obligatoire' + : null, + ), + const SizedBox(height: 12), + _Field( + label: 'Désignation', + controller: _desCtrl, + hint: 'Ex: Granulés PEHD', + validator: (v) => v == null || v.trim().isEmpty + ? 'Obligatoire' + : null, + ), + const SizedBox(height: 12), + DropdownButtonFormField( + value: _type, + decoration: _inputDeco('Type d\'article'), + items: const [ + DropdownMenuItem(value: 'MP', child: Text('Matière Première (MP)')), + DropdownMenuItem(value: 'PSF', child: Text('Produit Semi-Fini (PSF)')), + DropdownMenuItem(value: 'PF', child: Text('Produit Fini (PF)')), + ], + onChanged: (v) => setState(() => _type = v!), + ), + ], + ), + const SizedBox(height: 16), + _Card( + title: 'Unité & Prix', + children: [ + _Field( + label: 'Unité de mesure', + controller: _uniteCtrl, + hint: 'kg, unité, rouleau, m…', + ), + const SizedBox(height: 12), + _Field( + label: 'Prix unitaire (TND)', + controller: _prixCtrl, + hint: '0.000', + keyboardType: TextInputType.number, + validator: (v) { + if (v == null || v.isEmpty) return null; + if (double.tryParse(v) == null) return 'Nombre invalide'; + return null; + }, + ), + ], + ), + const SizedBox(height: 16), + _Card( + title: 'Stock', + children: [ + _Field( + label: 'Stock actuel', + controller: _stockActuelCtrl, + hint: '0', + keyboardType: TextInputType.number, + validator: (v) { + if (v == null || v.isEmpty) return null; + if (double.tryParse(v) == null) return 'Nombre invalide'; + return null; + }, + ), + const SizedBox(height: 12), + _Field( + label: 'Seuil minimum (alerte)', + controller: _stockMinCtrl, + hint: '0', + keyboardType: TextInputType.number, + validator: (v) { + if (v == null || v.isEmpty) return null; + if (double.tryParse(v) == null) return 'Nombre invalide'; + return null; + }, + ), + ], + ), + const SizedBox(height: 24), + SizedBox( + height: 52, + child: ElevatedButton( + onPressed: _saving ? null : _save, + style: ElevatedButton.styleFrom( + backgroundColor: Theme.of(context).colorScheme.primary, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + child: _saving + ? const SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : Text( + _isEdit ? 'Enregistrer les modifications' : 'Créer l\'article', + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 32), + ], + ), + ), + ), + ); + } +} + +class _Card extends StatelessWidget { + final String title; + final List children; + const _Card({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + 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: 13, + color: Color(0xFF6B7280))), + const SizedBox(height: 12), + ...children, + ], + ), + ); + } +} + +class _Field extends StatelessWidget { + final String label; + final TextEditingController controller; + final String? hint; + final bool enabled; + final TextInputType? keyboardType; + final String? Function(String?)? validator; + + const _Field({ + required this.label, + required this.controller, + this.hint, + this.enabled = true, + this.keyboardType, + this.validator, + }); + + @override + Widget build(BuildContext context) { + return TextFormField( + controller: controller, + enabled: enabled, + keyboardType: keyboardType, + decoration: _inputDeco(label).copyWith(hintText: hint), + validator: validator, + ); + } +} + +InputDecoration _inputDeco(String label) => InputDecoration( + labelText: label, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), + filled: true, + fillColor: const Color(0xFFF8F9FA), + contentPadding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + ); diff --git a/frontend/lib/screens/articles_screen.dart b/frontend/lib/screens/articles_screen.dart new file mode 100644 index 0000000..e980d95 --- /dev/null +++ b/frontend/lib/screens/articles_screen.dart @@ -0,0 +1,383 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; + +import '../providers/article_provider.dart'; +import '../models/article.dart'; +import '../widgets/app_drawer.dart'; +import 'article_form_screen.dart'; + +class ArticlesScreen extends StatefulWidget { + const ArticlesScreen({super.key}); + + @override + State createState() => _ArticlesScreenState(); +} + +class _ArticlesScreenState extends State { + final _searchController = TextEditingController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().load(); + }); + } + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF5F7FA), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text('Articles', + style: TextStyle(fontWeight: FontWeight.bold)), + actions: [ + IconButton( + icon: const Icon(Icons.refresh_outlined), + onPressed: () => context.read().load(), + ), + ], + ), + drawer: const AppDrawer(currentRoute: '/articles'), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => _openForm(context), + icon: const Icon(Icons.add), + label: const Text('Nouvel article'), + ), + body: Column( + children: [ + _SearchBar(controller: _searchController, provider: provider), + _FilterChips(provider: provider), + Expanded(child: _ArticleList(provider: provider)), + ], + ), + ); + } + + void _openForm(BuildContext context, [Article? article]) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ArticleFormScreen(article: article), + ), + ); + } +} + +class _SearchBar extends StatelessWidget { + final TextEditingController controller; + final ArticleProvider provider; + + const _SearchBar({required this.controller, required this.provider}); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + child: TextField( + controller: controller, + onChanged: provider.setSearch, + decoration: InputDecoration( + hintText: 'Rechercher par référence ou désignation…', + prefixIcon: const Icon(Icons.search, size: 20), + suffixIcon: controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear, size: 18), + onPressed: () { + controller.clear(); + provider.setSearch(''); + }, + ) + : null, + filled: true, + fillColor: const Color(0xFFF5F7FA), + contentPadding: const EdgeInsets.symmetric(vertical: 0, horizontal: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide.none, + ), + ), + ), + ); + } +} + +class _FilterChips extends StatelessWidget { + final ArticleProvider provider; + const _FilterChips({required this.provider}); + + static const filters = [ + ('TOUS', 'Tous'), + ('MP', 'Matières Premières'), + ('PSF', 'Semi-Finis'), + ('PF', 'Produits Finis'), + ]; + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.white, + padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: filters.map((f) { + final selected = provider.filterType == f.$1; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FilterChip( + label: Text(f.$2), + selected: selected, + onSelected: (_) => provider.setFilter(f.$1), + selectedColor: + Theme.of(context).colorScheme.primary.withOpacity(0.15), + checkmarkColor: Theme.of(context).colorScheme.primary, + labelStyle: TextStyle( + fontSize: 12, + color: selected + ? Theme.of(context).colorScheme.primary + : Colors.grey[700], + fontWeight: + selected ? FontWeight.w600 : FontWeight.normal, + ), + ), + ); + }).toList(), + ), + ), + ); + } +} + +class _ArticleList extends StatelessWidget { + final ArticleProvider provider; + const _ArticleList({required this.provider}); + + @override + Widget build(BuildContext context) { + 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!, style: TextStyle(color: Colors.grey[600])), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () => context.read().load(), + child: const Text('Réessayer'), + ), + ], + ), + ); + } + if (provider.articles.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[300]), + const SizedBox(height: 16), + Text('Aucun article trouvé', + style: TextStyle(color: Colors.grey[500], fontSize: 16)), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => context.read().load(), + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: provider.articles.length, + itemBuilder: (ctx, i) => + _ArticleCard(article: provider.articles[i]), + ), + ); + } +} + +class _ArticleCard extends StatelessWidget { + final Article article; + const _ArticleCard({required this.article}); + + static const typeColors = { + 'MP': Color(0xFF3B82F6), + 'PSF': Color(0xFF8B5CF6), + 'PF': Color(0xFF10B981), + }; + + @override + Widget build(BuildContext context) { + final color = typeColors[article.type] ?? Colors.grey; + final priceFmt = NumberFormat.currency( + locale: 'fr_TN', symbol: 'TND', decimalDigits: 3); + + return 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)), + ], + border: article.enAlerte + ? Border.all(color: Colors.red[300]!, width: 1.5) + : null, + ), + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + borderRadius: BorderRadius.circular(10), + ), + child: Center( + child: Text( + article.type, + style: TextStyle( + color: color, + fontWeight: FontWeight.bold, + fontSize: 12), + ), + ), + ), + title: Row( + children: [ + Expanded( + child: Text( + article.designation, + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 14), + ), + ), + if (article.enAlerte) + const Icon(Icons.warning_amber, + color: Colors.orange, size: 16), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Text('Réf: ${article.reference}', + style: TextStyle(color: Colors.grey[600], fontSize: 12)), + const SizedBox(height: 2), + Row( + children: [ + Text( + 'Stock: ${article.stockActuel} ${article.uniteMesure ?? ''}', + style: TextStyle( + fontSize: 12, + color: article.enAlerte + ? Colors.red[600] + : Colors.grey[600], + fontWeight: article.enAlerte + ? FontWeight.w600 + : FontWeight.normal, + ), + ), + const SizedBox(width: 12), + Text( + priceFmt.format(article.prixUnitaire), + style: const TextStyle( + fontSize: 12, color: Color(0xFF374151)), + ), + ], + ), + ], + ), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert, color: Colors.grey), + onSelected: (val) => _onAction(context, val), + itemBuilder: (_) => [ + const PopupMenuItem( + value: 'edit', + child: Row(children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Text('Modifier'), + ])), + const PopupMenuItem( + value: 'delete', + child: Row(children: [ + Icon(Icons.delete_outline, size: 18, color: Colors.red), + SizedBox(width: 8), + Text('Supprimer', + style: TextStyle(color: Colors.red)), + ])), + ], + ), + ), + ); + } + + void _onAction(BuildContext context, String action) { + if (action == 'edit') { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ArticleFormScreen(article: article)), + ); + } else if (action == 'delete') { + _confirmDelete(context); + } + } + + void _confirmDelete(BuildContext context) { + showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Supprimer l\'article ?'), + content: Text( + 'Voulez-vous vraiment archiver "${article.designation}" ?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Annuler')), + ElevatedButton( + style: ElevatedButton.styleFrom(backgroundColor: Colors.red), + onPressed: () async { + Navigator.pop(context); + final ok = await context + .read() + .delete(article.id!); + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(ok + ? 'Article archivé' + : 'Erreur lors de la suppression'), + backgroundColor: ok ? Colors.green : Colors.red, + )); + } + }, + child: const Text('Supprimer', + style: TextStyle(color: Colors.white)), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/services/article_service.dart b/frontend/lib/services/article_service.dart new file mode 100644 index 0000000..e5c950a --- /dev/null +++ b/frontend/lib/services/article_service.dart @@ -0,0 +1,28 @@ +import 'api_client.dart'; +import '../models/article.dart'; + +class ArticleService { + static Future> fetchAll() async { + final res = await ApiClient.instance.get('/articles'); + return (res.data as List).map((e) => Article.fromJson(e)).toList(); + } + + static Future> fetchByType(String type) async { + final res = await ApiClient.instance.get('/articles/type/$type'); + return (res.data as List).map((e) => Article.fromJson(e)).toList(); + } + + static Future
create(Article article) async { + final res = await ApiClient.instance.post('/articles', data: article.toJson()); + return Article.fromJson(res.data); + } + + static Future
update(int id, Article article) async { + final res = await ApiClient.instance.put('/articles/$id', data: article.toJson()); + return Article.fromJson(res.data); + } + + static Future delete(int id) async { + await ApiClient.instance.delete('/articles/$id'); + } +}