Add equipment management features (and qr generation support)

This commit is contained in:
ElPoyo
2025-10-21 16:32:18 +02:00
parent ef638d8c8c
commit ae3a1b7227
18 changed files with 4489 additions and 7 deletions

View File

@@ -0,0 +1,691 @@
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/views/equipment_form/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,
),
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(_getCategoryLabel(category)),
);
}).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(_getStatusLabel(status)),
);
}).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 = EquipmentIdGenerator.generate(brand: brand, model: model, number: null);
String uniqueId = await EquipmentIdGenerator.ensureUniqueId(baseId, _equipmentService);
ids.add(uniqueId);
} else {
for (final num in numbers) {
String baseId = EquipmentIdGenerator.generate(brand: brand, model: model, number: num);
String uniqueId = await EquipmentIdGenerator.ensureUniqueId(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);
}
}
// Correction des enums dans _getCategoryLabel
String _getCategoryLabel(EquipmentCategory category) {
switch (category) {
case EquipmentCategory.lighting:
return 'Lumière';
case EquipmentCategory.sound:
return 'Son';
case EquipmentCategory.video:
return 'Vidéo';
case EquipmentCategory.effect:
return 'Effet';
case EquipmentCategory.structure:
return 'Structure';
case EquipmentCategory.consumable:
return 'Consommable';
case EquipmentCategory.cable:
return 'Câble';
case EquipmentCategory.other:
default:
return 'Autre';
}
}
// Correction des enums dans _getStatusLabel
String _getStatusLabel(EquipmentStatus status) {
switch (status) {
case EquipmentStatus.available:
return 'Disponible';
case EquipmentStatus.inUse:
return 'En prestation';
case EquipmentStatus.rented:
return 'Loué';
case EquipmentStatus.lost:
return 'Perdu';
case EquipmentStatus.outOfService:
return 'HS';
case EquipmentStatus.maintenance:
return 'Maintenance';
default:
return 'Autre';
}
}
}