Files
EM2_ERP/em2rp/lib/views/equipment_form_page.dart
ElPoyo df6d54a007 Refactor: Centralisation des labels et icônes pour les enums
Centralise la gestion des libellés, couleurs et icônes pour `EquipmentStatus`, `EquipmentCategory`, et `ContainerType` en utilisant des extensions Dart.

- Ajout de nouvelles icônes SVG pour `flight-case`, `truss` et `tape`.
- Refactorisation des vues pour utiliser les nouvelles extensions, supprimant ainsi la logique d'affichage dupliquée.
- Mise à jour des `ChoiceChip` et des listes de filtres pour afficher les icônes à côté des labels.
2025-10-29 18:43:24 +01:00

658 lines
27 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:em2rp/models/equipment_model.dart';
import 'package:em2rp/providers/equipment_provider.dart';
import 'package:em2rp/providers/local_user_provider.dart';
import 'package:em2rp/services/equipment_service.dart';
import 'package:em2rp/utils/colors.dart';
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
import 'package:intl/intl.dart';
import 'package:em2rp/views/equipment_form/brand_model_selector.dart';
import 'package:em2rp/utils/id_generator.dart';
class EquipmentFormPage extends StatefulWidget {
final EquipmentModel? equipment;
const EquipmentFormPage({super.key, this.equipment});
@override
State<EquipmentFormPage> createState() => _EquipmentFormPageState();
}
class _EquipmentFormPageState extends State<EquipmentFormPage> {
final _formKey = GlobalKey<FormState>();
final EquipmentService _equipmentService = EquipmentService();
// Controllers
final TextEditingController _identifierController = TextEditingController();
final TextEditingController _brandController = TextEditingController();
final TextEditingController _modelController = TextEditingController();
final TextEditingController _purchasePriceController = TextEditingController();
final TextEditingController _rentalPriceController = TextEditingController();
final TextEditingController _totalQuantityController = TextEditingController();
final TextEditingController _criticalThresholdController = TextEditingController();
final TextEditingController _notesController = TextEditingController();
final TextEditingController _quantityToAddController = TextEditingController(text: '1');
// State variables
EquipmentCategory _selectedCategory = EquipmentCategory.other;
EquipmentStatus _selectedStatus = EquipmentStatus.available;
DateTime? _purchaseDate;
DateTime? _lastMaintenanceDate;
DateTime? _nextMaintenanceDate;
List<String> _selectedParentBoxIds = [];
List<EquipmentModel> _availableBoxes = [];
bool _isLoading = false;
bool _isLoadingBoxes = true;
bool _addMultiple = false;
String? _selectedBrand;
List<String> _filteredModels = [];
@override
void initState() {
super.initState();
_loadAvailableBoxes();
WidgetsBinding.instance.addPostFrameCallback((_) {
final provider = Provider.of<EquipmentProvider>(context, listen: false);
provider.loadBrands();
provider.loadModels();
});
if (widget.equipment != null) {
_populateFields();
}
}
void _populateFields() {
final equipment = widget.equipment!;
_identifierController.text = equipment.id;
_brandController.text = equipment.brand ?? '';
_selectedBrand = equipment.brand;
_modelController.text = equipment.model ?? '';
_selectedCategory = equipment.category;
_selectedStatus = equipment.status;
_purchasePriceController.text = equipment.purchasePrice?.toStringAsFixed(2) ?? '';
_rentalPriceController.text = equipment.rentalPrice?.toStringAsFixed(2) ?? '';
_totalQuantityController.text = equipment.totalQuantity?.toString() ?? '';
_criticalThresholdController.text = equipment.criticalThreshold?.toString() ?? '';
_purchaseDate = equipment.purchaseDate;
_lastMaintenanceDate = equipment.lastMaintenanceDate;
_nextMaintenanceDate = equipment.nextMaintenanceDate;
_selectedParentBoxIds = List.from(equipment.parentBoxIds);
_notesController.text = equipment.notes ?? '';
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
_loadFilteredModels(_selectedBrand!);
}
}
Future<void> _loadAvailableBoxes() async {
try {
final boxes = await _equipmentService.getBoxes();
setState(() {
_availableBoxes = boxes;
_isLoadingBoxes = false;
});
} catch (e) {
setState(() {
_isLoadingBoxes = false;
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors du chargement des boîtes : $e')),
);
}
}
}
Future<void> _loadFilteredModels(String brand) async {
try {
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
final models = await equipmentProvider.loadModelsByBrand(brand);
setState(() {
_filteredModels = models;
});
} catch (e) {
setState(() {
_filteredModels = [];
});
}
}
@override
void dispose() {
_identifierController.dispose();
_brandController.dispose();
_modelController.dispose();
_purchasePriceController.dispose();
_rentalPriceController.dispose();
_totalQuantityController.dispose();
_criticalThresholdController.dispose();
_notesController.dispose();
_quantityToAddController.dispose();
super.dispose();
}
bool get _isConsumable => _selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable;
@override
Widget build(BuildContext context) {
final localUserProvider = Provider.of<LocalUserProvider>(context);
final hasManagePermission = localUserProvider.hasPermission('manage_equipment');
final isEditing = widget.equipment != null;
return Scaffold(
appBar: CustomAppBar(
title: isEditing ? 'Modifier l\'équipement' : 'Nouvel équipement',
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: SingleChildScrollView(
padding: const EdgeInsets.all(24.0),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Identifiant (généré ou saisi)
TextFormField(
controller: _identifierController,
decoration: InputDecoration(
labelText: 'Identifiant *',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.tag),
hintText: isEditing ? null : 'Laissez vide pour générer automatiquement',
helperText: isEditing ? 'Non modifiable' : 'Format auto: {Marque4Chars}_{Modèle}',
),
enabled: !isEditing,
validator: (value) {
if (value != null && value.isNotEmpty) {
// Empêcher les ID commençant par BOX_ (réservé aux containers)
if (value.toUpperCase().startsWith('BOX_')) {
return 'Les ID commençant par BOX_ sont réservés aux containers';
}
}
return null;
},
),
const SizedBox(height: 16),
// Case à cocher "Ajouter plusieurs" (uniquement en mode création)
if (!isEditing) ...[
Row(
children: [
Expanded(
flex: 2,
child: CheckboxListTile(
title: const Text('Ajouter plusieurs équipements'),
subtitle: const Text('Créer plusieurs équipements numérotés'),
value: _addMultiple,
contentPadding: EdgeInsets.zero,
onChanged: (bool? value) {
setState(() {
_addMultiple = value ?? false;
});
},
),
),
if (_addMultiple) ...[
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _quantityToAddController,
decoration: const InputDecoration(
labelText: 'Quantité ou range',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.plus_one),
hintText: '5 ou 6-18',
helperText: 'Ex: 5 ou 6-18',
),
keyboardType: TextInputType.text,
validator: (value) {
if (_addMultiple) {
if (value == null || value.isEmpty) return 'Requis';
// Vérifier si c'est un nombre simple ou une range
if (value.contains('-')) {
final parts = value.split('-');
if (parts.length != 2) return 'Format invalide';
final start = int.tryParse(parts[0].trim());
final end = int.tryParse(parts[1].trim());
if (start == null || end == null) return 'Nombres invalides';
if (start >= end) return 'Le début doit être < fin';
if (end - start > 100) return 'Max 100 équipements';
} else {
final num = int.tryParse(value);
if (num == null || num < 1 || num > 100) return '1-100';
}
}
return null;
},
),
),
],
],
),
const SizedBox(height: 16),
],
// Sélecteur Marque/Modèle
BrandModelSelector(
brandController: _brandController,
modelController: _modelController,
selectedBrand: _selectedBrand,
filteredModels: _filteredModels,
onBrandChanged: (brand) {
setState(() {
_selectedBrand = brand;
});
if (brand != null && brand.isNotEmpty) {
_loadFilteredModels(brand);
} else {
setState(() {
_filteredModels = [];
});
}
},
onModelsChanged: (models) {
setState(() {
_filteredModels = models;
});
},
),
const SizedBox(height: 16),
// Catégorie et Statut
Row(
children: [
Expanded(
child: DropdownButtonFormField<EquipmentCategory>(
value: _selectedCategory,
decoration: const InputDecoration(
labelText: 'Catégorie *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.category),
),
items: EquipmentCategory.values.map((category) {
return DropdownMenuItem(
value: category,
child: Text(category.label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedCategory = value;
});
}
},
),
),
// Afficher le statut uniquement si ce n'est pas un consommable ou câble
if (!_isConsumable) ...[
const SizedBox(width: 16),
Expanded(
child: DropdownButtonFormField<EquipmentStatus>(
value: _selectedStatus,
decoration: const InputDecoration(
labelText: 'Statut *',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.info),
),
items: EquipmentStatus.values.map((status) {
return DropdownMenuItem(
value: status,
child: Text(status.label),
);
}).toList(),
onChanged: (value) {
if (value != null) {
setState(() {
_selectedStatus = value;
});
}
},
),
),
],
],
),
const SizedBox(height: 16),
// Prix
if (hasManagePermission) ...[
Row(
children: [
Expanded(
child: TextFormField(
controller: _purchasePriceController,
decoration: const InputDecoration(
labelText: 'Prix d\'achat (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.euro),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _rentalPriceController,
decoration: const InputDecoration(
labelText: 'Prix de location (€)',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.attach_money),
),
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
),
),
],
),
const SizedBox(height: 16),
],
// Quantités pour consommables
if (_isConsumable) ...[
const Divider(),
const Text('Gestion des quantités', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: TextFormField(
controller: _totalQuantityController,
decoration: const InputDecoration(
labelText: 'Quantité totale',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.inventory),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
const SizedBox(width: 16),
Expanded(
child: TextFormField(
controller: _criticalThresholdController,
decoration: const InputDecoration(
labelText: 'Seuil critique',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.warning),
),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
),
),
],
),
const SizedBox(height: 16),
],
// Boîtes parentes
const Divider(),
const Text('Boîtes parentes', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
_isLoadingBoxes
? const Center(child: CircularProgressIndicator())
: _buildParentBoxesSelector(),
const SizedBox(height: 16),
// Dates
const Divider(),
const Text('Dates', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
const SizedBox(height: 16),
_buildDateField(label: 'Dernière maintenance', icon: Icons.build, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
const SizedBox(height: 16),
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
const SizedBox(height: 16),
// Notes
const Divider(),
TextFormField(
controller: _notesController,
decoration: const InputDecoration(
labelText: 'Notes',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.notes),
),
maxLines: 3,
),
const SizedBox(height: 24),
// Boutons
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler'),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: _saveEquipment,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.rouge,
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
),
child: Text(isEditing ? 'Enregistrer' : 'Créer', style: const TextStyle(color: Colors.white)),
),
],
),
],
),
),
),
);
}
Widget _buildParentBoxesSelector() {
if (_availableBoxes.isEmpty) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16.0),
child: Text('Aucune boîte disponible'),
),
);
}
return Card(
child: Column(
children: _availableBoxes.map((box) {
final isSelected = _selectedParentBoxIds.contains(box.id);
return CheckboxListTile(
title: Text(box.name),
subtitle: box.model != null ? Text('Modèle: {box.model}') : null,
value: isSelected,
onChanged: (bool? value) {
setState(() {
if (value == true) {
_selectedParentBoxIds.add(box.id);
} else {
_selectedParentBoxIds.remove(box.id);
}
});
},
);
}).toList(),
),
);
}
Widget _buildDateField({required String label, required IconData icon, required DateTime? value, required VoidCallback onTap}) {
return InkWell(
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
border: const OutlineInputBorder(),
prefixIcon: Icon(icon),
suffixIcon: value != null
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
setState(() {
if (label.contains('achat')) {
_purchaseDate = null;
} else if (label.contains('Dernière')) {
_lastMaintenanceDate = null;
} else if (label.contains('Prochaine')) {
_nextMaintenanceDate = null;
}
});
},
)
: null,
),
child: Text(
value != null ? DateFormat('dd/MM/yyyy').format(value) : 'Sélectionner une date',
style: TextStyle(color: value != null ? Colors.black : Colors.grey),
),
),
);
}
Future<void> _selectDate(BuildContext context, String field) async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
switch (field) {
case 'purchase':
_purchaseDate = picked;
break;
case 'lastMaintenance':
_lastMaintenanceDate = picked;
break;
case 'nextMaintenance':
_nextMaintenanceDate = picked;
break;
}
});
}
}
Future<void> _saveEquipment() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
final isEditing = widget.equipment != null;
int? availableQuantity;
if (_isConsumable && _totalQuantityController.text.isNotEmpty) {
final totalQuantity = int.parse(_totalQuantityController.text);
if (isEditing && widget.equipment!.availableQuantity != null) {
availableQuantity = widget.equipment!.availableQuantity;
} else {
availableQuantity = totalQuantity;
}
}
// Validation marque/modèle obligatoires
String brand = _brandController.text.trim();
String model = _modelController.text.trim();
if (brand.isEmpty || model.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('La marque et le modèle sont obligatoires')),
);
return;
}
// Génération d'identifiant si vide
List<String> ids = [];
List<int> numbers = [];
if (!isEditing && _identifierController.text.isEmpty) {
// Gérer la range ou nombre simple
final quantityText = _quantityToAddController.text.trim();
if (_addMultiple && quantityText.contains('-')) {
// Range: ex "6-18"
final parts = quantityText.split('-');
final start = int.parse(parts[0].trim());
final end = int.parse(parts[1].trim());
for (int i = start; i <= end; i++) {
numbers.add(i);
}
} else if (_addMultiple) {
// Nombre simple
final nbToAdd = int.tryParse(quantityText) ?? 1;
for (int i = 1; i <= nbToAdd; i++) {
numbers.add(i);
}
}
// Générer les IDs
if (numbers.isEmpty) {
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
ids.add(uniqueId);
} else {
for (final num in numbers) {
String baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: num);
String uniqueId = await IdGenerator.ensureUniqueEquipmentId(baseId, _equipmentService);
ids.add(uniqueId);
}
}
} else {
ids.add(_identifierController.text.trim());
}
// Création des équipements
for (final id in ids) {
final now = DateTime.now();
final equipment = EquipmentModel(
id: id,
name: id, // Utilisation de l'identifiant comme nom
brand: brand,
model: model,
category: _selectedCategory,
status: _selectedStatus,
purchasePrice: _purchasePriceController.text.isNotEmpty ? double.tryParse(_purchasePriceController.text) : null,
rentalPrice: _rentalPriceController.text.isNotEmpty ? double.tryParse(_rentalPriceController.text) : null,
totalQuantity: _isConsumable ? int.tryParse(_totalQuantityController.text) : null,
criticalThreshold: _isConsumable ? int.tryParse(_criticalThresholdController.text) : null,
purchaseDate: _purchaseDate,
lastMaintenanceDate: _lastMaintenanceDate,
nextMaintenanceDate: _nextMaintenanceDate,
parentBoxIds: _selectedParentBoxIds,
notes: _notesController.text,
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
updatedAt: now,
availableQuantity: availableQuantity,
);
if (isEditing) {
await equipmentProvider.updateEquipment(
equipment.id,
equipment.toMap(),
);
} else {
await equipmentProvider.addEquipment(equipment);
}
}
if (mounted) {
Navigator.pop(context, true);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
}