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