Files
EM2_ERP/em2rp/lib/views/equipment_form_page.dart
ElPoyo 3fab69cb00 feat: Gestion complète des containers et refactorisation du matériel
Ajout de la gestion des containers (création, édition, suppression, affichage des détails).
Introduction d'un système de génération de QR codes unifié et d'un mode de sélection multiple.

**Features:**
- **Gestion des Containers :**
    - Nouvelle page de gestion des containers (`container_management_page.dart`) avec recherche et filtres.
    - Formulaire de création/édition de containers (`container_form_page.dart`) avec génération d'ID automatique.
    - Page de détails d'un container (`container_detail_page.dart`) affichant son contenu et ses caractéristiques.
    - Ajout des routes et du provider (`ContainerProvider`) nécessaires.
- **Modèle de Données :**
    - Ajout du `ContainerModel` pour représenter les boîtes, flight cases, etc.
    - Le modèle `EquipmentModel` a été enrichi avec des caractéristiques physiques (poids, dimensions).
- **QR Codes :**
    - Nouveau service unifié (`UnifiedPDFGeneratorService`) pour générer des PDFs de QR codes pour n'importe quelle entité.
    - Services `PDFGeneratorService` et `ContainerPDFGeneratorService` transformés en wrappers pour maintenir la compatibilité.
    - Amélioration de la performance de la génération de QR codes en masse.
- **Interface Utilisateur (UI/UX) :**
    - Nouvelle page de détails pour le matériel (`equipment_detail_page.dart`).
    - Ajout d'un `SelectionModeMixin` pour gérer la sélection multiple dans les pages de gestion.
    - Dialogues réutilisables pour l'affichage de QR codes (`QRCodeDialog`) et la sélection de format d'impression (`QRCodeFormatSelectorDialog`).
    - Ajout d'un bouton "Gérer les boîtes" sur la page de gestion du matériel.

**Refactorisation :**
- L' `IdGenerator` a été déplacé dans le répertoire `utils` et étendu pour gérer les containers.
- Mise à jour de nombreuses dépendances `pubspec.yaml` vers des versions plus récentes.
- Séparation de la logique d'affichage des containers et du matériel dans des widgets dédiés (`ContainerHeaderCard`, `EquipmentParentContainers`, etc.).
2025-10-29 10:57:42 +01:00

701 lines
28 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(_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 = 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);
}
}
// 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';
}
}
}