feat(frontend): module Articles complet (liste, création, modification, suppression)
- Article model (MP/PSF/PF, stock, seuil alerte) - ArticleService : fetchAll, fetchByType, create, update, delete - ArticleProvider : filtrage par type + recherche temps réel - ArticlesScreen : liste, chips filtres, barre recherche, alertes stock - ArticleFormScreen : formulaire création/édition avec validation - Rapport PFE : section 5.6 module Articles Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
601a7d0373
commit
ed21c3cb80
|
|
@ -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]*
|
*[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<Article>
|
||||||
|
├── 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
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@ import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import 'providers/auth_provider.dart';
|
import 'providers/auth_provider.dart';
|
||||||
import 'providers/dashboard_provider.dart';
|
import 'providers/dashboard_provider.dart';
|
||||||
|
import 'providers/article_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 'widgets/app_drawer.dart';
|
import 'widgets/app_drawer.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
|
|
@ -14,6 +16,7 @@ void main() {
|
||||||
providers: [
|
providers: [
|
||||||
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
ChangeNotifierProvider(create: (_) => AuthProvider()),
|
||||||
ChangeNotifierProvider(create: (_) => DashboardProvider()),
|
ChangeNotifierProvider(create: (_) => DashboardProvider()),
|
||||||
|
ChangeNotifierProvider(create: (_) => ArticleProvider()),
|
||||||
],
|
],
|
||||||
child: const RayhanApp(),
|
child: const RayhanApp(),
|
||||||
),
|
),
|
||||||
|
|
@ -39,7 +42,7 @@ class RayhanApp extends StatelessWidget {
|
||||||
routes: [
|
routes: [
|
||||||
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
GoRoute(path: '/login', builder: (_, __) => const LoginScreen()),
|
||||||
GoRoute(path: '/dashboard', builder: (_, __) => const DashboardScreen()),
|
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: '/ventes', builder: (_, __) => const _PlaceholderScreen(title: 'Ventes', route: '/ventes')),
|
||||||
GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')),
|
GoRoute(path: '/achats', builder: (_, __) => const _PlaceholderScreen(title: 'Achats', route: '/achats')),
|
||||||
GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')),
|
GoRoute(path: '/production', builder: (_, __) => const _PlaceholderScreen(title: 'Production', route: '/production')),
|
||||||
|
|
|
||||||
|
|
@ -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<String, dynamic> 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<String, dynamic> 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<String, String> typeLabels = {
|
||||||
|
'MP': 'Matière Première',
|
||||||
|
'PSF': 'Produit Semi-Fini',
|
||||||
|
'PF': 'Produit Fini',
|
||||||
|
};
|
||||||
|
|
||||||
|
String get typeLabel => typeLabels[type] ?? type;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/article.dart';
|
||||||
|
import '../services/article_service.dart';
|
||||||
|
|
||||||
|
class ArticleProvider extends ChangeNotifier {
|
||||||
|
List<Article> _articles = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _error;
|
||||||
|
String _filterType = 'TOUS';
|
||||||
|
String _search = '';
|
||||||
|
|
||||||
|
List<Article> get articles => _filtered();
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
String? get error => _error;
|
||||||
|
String get filterType => _filterType;
|
||||||
|
|
||||||
|
List<Article> _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<void> load() async {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
notifyListeners();
|
||||||
|
try {
|
||||||
|
_articles = await ArticleService.fetchAll();
|
||||||
|
} catch (_) {
|
||||||
|
_error = 'Impossible de charger les articles.';
|
||||||
|
} finally {
|
||||||
|
_isLoading = false;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> create(Article article) async {
|
||||||
|
try {
|
||||||
|
final created = await ArticleService.create(article);
|
||||||
|
_articles.add(created);
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> 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<bool> delete(int id) async {
|
||||||
|
try {
|
||||||
|
await ArticleService.delete(id);
|
||||||
|
_articles.removeWhere((a) => a.id == id);
|
||||||
|
notifyListeners();
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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<ArticleFormScreen> createState() => _ArticleFormScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArticleFormScreenState extends State<ArticleFormScreen> {
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
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<void> _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<ArticleProvider>();
|
||||||
|
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<String>(
|
||||||
|
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<Widget> 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),
|
||||||
|
);
|
||||||
|
|
@ -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<ArticlesScreen> createState() => _ArticlesScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ArticlesScreenState extends State<ArticlesScreen> {
|
||||||
|
final _searchController = TextEditingController();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
context.read<ArticleProvider>().load();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<ArticleProvider>();
|
||||||
|
|
||||||
|
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<ArticleProvider>().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<ArticleProvider>().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<ArticleProvider>().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<String>(
|
||||||
|
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<ArticleProvider>()
|
||||||
|
.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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
import 'api_client.dart';
|
||||||
|
import '../models/article.dart';
|
||||||
|
|
||||||
|
class ArticleService {
|
||||||
|
static Future<List<Article>> fetchAll() async {
|
||||||
|
final res = await ApiClient.instance.get('/articles');
|
||||||
|
return (res.data as List).map((e) => Article.fromJson(e)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<List<Article>> 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<Article> create(Article article) async {
|
||||||
|
final res = await ApiClient.instance.post('/articles', data: article.toJson());
|
||||||
|
return Article.fromJson(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<Article> update(int id, Article article) async {
|
||||||
|
final res = await ApiClient.instance.put('/articles/$id', data: article.toJson());
|
||||||
|
return Article.fromJson(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> delete(int id) async {
|
||||||
|
await ApiClient.instance.delete('/articles/$id');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue