Cette mise à jour verrouille l'accès direct à Firestore depuis le client pour renforcer la sécurité et introduit une gestion complète des prix HT/TTC dans toute l'application. Elle apporte également des améliorations significatives des permissions, des optimisations de performance et de nouvelles fonctionnalités.
### Sécurité et Backend
- **Firestore Rules :** Ajout de `firestore.rules` qui bloque par défaut tous les accès en lecture/écriture depuis le client. Toutes les opérations de données doivent maintenant passer par les Cloud Functions, renforçant considérablement la sécurité.
- **Index Firestore :** Création d'un fichier `firestore.indexes.json` pour optimiser les requêtes sur la collection `events`.
- **Cloud Functions :** Les fonctions de création/mise à jour d'événements ont été adaptées pour accepter des ID de documents (utilisateurs, type d'événement) et les convertir en `DocumentReference` côté serveur, simplifiant les appels depuis le client.
### Gestion des Prix HT/TTC
- **Calcul Automatisé :** Introduction d'un helper `PriceHelpers` et d'un widget `PriceHtTtcFields` pour calculer et synchroniser automatiquement les prix HT et TTC dans le formulaire d'événement.
- **Affichage Détaillé :**
- Les détails des événements et des options affichent désormais les prix HT, la TVA et le TTC séparément pour plus de clarté.
- Le prix de base (`basePrice`) est maintenant traité comme un prix TTC dans toute l'application.
### Permissions et Rôles
- **Centralisation (`AppPermission`) :** Création d'une énumération `AppPermission` pour centraliser toutes les permissions de l'application, avec descriptions et catégories.
- **Rôles Prédéfinis :** Définition de rôles standards (Admin, Manager, Technicien, User) avec des jeux de permissions prédéfinis.
- **Filtre par Utilisateur :** Ajout d'un filtre par utilisateur sur la page Calendrier, visible uniquement pour les utilisateurs ayant la permission `view_all_user_events`.
### Améliorations et Optimisations (Frontend)
- **`DebugLog` :** Ajout d'un utilitaire `DebugLog` pour gérer les logs, qui sont automatiquement désactivés en mode production.
- **Optimisation du Sélecteur d'Équipement :**
- La boîte de dialogue de sélection d'équipement a été lourdement optimisée pour éviter les reconstructions complètes de la liste lors de la sélection/désélection d'items.
- Utilisation de `ValueNotifier` et de caches locaux (`_cachedContainers`, `_cachedEquipment`) pour des mises à jour d'UI plus ciblées et fluides.
- La position du scroll est désormais préservée.
- **Catégorie d'Équipement :** Ajout de la catégorie `Vehicle` (Véhicule) pour les équipements.
- **Formulaires :** Les formulaires de création/modification d'événements et d'équipements ont été nettoyés de leurs logs de débogage excessifs.
717 lines
30 KiB
Dart
717 lines
30 KiB
Dart
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/models/container_model.dart';
|
|
import 'package:em2rp/providers/equipment_provider.dart';
|
|
import 'package:em2rp/providers/container_provider.dart';
|
|
import 'package:em2rp/providers/local_user_provider.dart';
|
|
import 'package:em2rp/services/equipment_service.dart';
|
|
import 'package:em2rp/services/container_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';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
import 'package:em2rp/views/widgets/equipment/parent_boxes_selector.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<ContainerModel> _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!;
|
|
setState(() {
|
|
_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;
|
|
_notesController.text = equipment.notes ?? '';
|
|
});
|
|
|
|
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
|
|
|
// Charger les containers contenant cet équipement depuis Firestore
|
|
_loadCurrentContainers(equipment.id);
|
|
|
|
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
|
_loadFilteredModels(_selectedBrand!);
|
|
}
|
|
}
|
|
|
|
/// Charge les containers qui contiennent actuellement cet équipement
|
|
Future<void> _loadCurrentContainers(String equipmentId) async {
|
|
try {
|
|
final containers = await containerEquipmentService.getContainersByEquipment(equipmentId);
|
|
setState(() {
|
|
_selectedParentBoxIds = containers.map((c) => c.id).toList();
|
|
});
|
|
DebugLog.info('[EquipmentForm] Loaded ${containers.length} containers for equipment $equipmentId');
|
|
DebugLog.info('[EquipmentForm] Selected container IDs: $_selectedParentBoxIds');
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentForm] Error loading containers for equipment', e);
|
|
}
|
|
}
|
|
|
|
Future<void> _loadAvailableBoxes() async {
|
|
try {
|
|
final boxes = await _equipmentService.getBoxes();
|
|
DebugLog.info('[EquipmentForm] Loaded ${boxes.length} boxes from service');
|
|
for (var box in boxes) {
|
|
DebugLog.info('[EquipmentForm] Box loaded - ID: ${box.id}, Name: ${box.name}');
|
|
}
|
|
setState(() {
|
|
_availableBoxes = boxes;
|
|
_isLoadingBoxes = false;
|
|
});
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentForm] Error loading boxes', 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 boites';
|
|
}
|
|
}
|
|
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 SizedBox(height: 8),
|
|
_isLoadingBoxes
|
|
? const Card(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32.0),
|
|
child: 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() {
|
|
return ParentBoxesSelector(
|
|
availableBoxes: _availableBoxes,
|
|
selectedBoxIds: _selectedParentBoxIds,
|
|
onSelectionChanged: (newSelection) {
|
|
setState(() {
|
|
_selectedParentBoxIds = newSelection;
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
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: [], // On ne stocke plus les parentBoxIds dans l'équipement
|
|
notes: _notesController.text,
|
|
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
|
updatedAt: now,
|
|
availableQuantity: availableQuantity,
|
|
);
|
|
if (isEditing) {
|
|
await equipmentProvider.updateEquipment(equipment);
|
|
|
|
// Synchroniser les containers : mettre à jour equipmentIds des containers
|
|
// Charger les anciens containers depuis Firestore
|
|
final oldContainers = await containerEquipmentService.getContainersByEquipment(equipment.id);
|
|
final oldParentBoxIds = oldContainers.map((c) => c.id).toList();
|
|
final newParentBoxIds = _selectedParentBoxIds;
|
|
|
|
// Boîtes ajoutées : ajouter cet équipement à leur equipmentIds
|
|
final addedBoxes = newParentBoxIds.where((id) => !oldParentBoxIds.contains(id));
|
|
for (final boxId in addedBoxes) {
|
|
try {
|
|
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
|
|
await containerProvider.addEquipmentToContainer(
|
|
containerId: boxId,
|
|
equipmentId: equipment.id,
|
|
);
|
|
DebugLog.info('[EquipmentForm] Added equipment ${equipment.id} to container $boxId');
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentForm] Error adding equipment to container $boxId', e);
|
|
}
|
|
}
|
|
|
|
// Boîtes retirées : retirer cet équipement de leur equipmentIds
|
|
final removedBoxes = oldParentBoxIds.where((id) => !newParentBoxIds.contains(id));
|
|
for (final boxId in removedBoxes) {
|
|
try {
|
|
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
|
|
await containerProvider.removeEquipmentFromContainer(
|
|
containerId: boxId,
|
|
equipmentId: equipment.id,
|
|
);
|
|
DebugLog.info('[EquipmentForm] Removed equipment ${equipment.id} from container $boxId');
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentForm] Error removing equipment from container $boxId', e);
|
|
}
|
|
}
|
|
} else {
|
|
await equipmentProvider.addEquipment(equipment);
|
|
|
|
// Pour un nouvel équipement, ajouter à tous les containers sélectionnés
|
|
for (final boxId in _selectedParentBoxIds) {
|
|
try {
|
|
final containerProvider = Provider.of<ContainerProvider>(context, listen: false);
|
|
await containerProvider.addEquipmentToContainer(
|
|
containerId: boxId,
|
|
equipmentId: equipment.id,
|
|
);
|
|
DebugLog.info('[EquipmentForm] Added new equipment ${equipment.id} to container $boxId');
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentForm] Error adding new equipment to container $boxId', e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|