1043 lines
46 KiB
Dart
1043 lines
46 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/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/subcategory_selector.dart';
|
|
import 'package:em2rp/utils/id_generator.dart';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
import 'package:em2rp/utils/debouncer.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 _subCategoryController = 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');
|
|
|
|
// Physical characteristics controllers
|
|
final TextEditingController _weightController = TextEditingController();
|
|
final TextEditingController _lengthController = TextEditingController();
|
|
final TextEditingController _widthController = TextEditingController();
|
|
final TextEditingController _heightController = TextEditingController();
|
|
|
|
// State variables
|
|
EquipmentCategory? _selectedCategory; // Nullable by default to force selection
|
|
EquipmentStatus _selectedStatus = EquipmentStatus.available;
|
|
DateTime? _purchaseDate;
|
|
DateTime? _lastMaintenanceDate;
|
|
DateTime? _nextMaintenanceDate;
|
|
bool _isLoading = false;
|
|
String? _selectedBrand;
|
|
List<String> _filteredModels = [];
|
|
List<String> _filteredSubCategories = [];
|
|
|
|
// ID auto-generation and check
|
|
final ValueNotifier<bool> _autoGenerateIdNotifier = ValueNotifier<bool>(true);
|
|
final _idCheckDebouncer = Debouncer(delay: const Duration(milliseconds: 500));
|
|
String? _idConflictMessage;
|
|
List<String> _candidateIds = [];
|
|
bool _isCalculatingIds = false;
|
|
|
|
@override
|
|
void initState() {
|
|
_candidateIds = [];
|
|
_isCalculatingIds = false;
|
|
super.initState();
|
|
|
|
// Set default dates to today for new equipment
|
|
if (widget.equipment == null) {
|
|
_purchaseDate = DateTime.now();
|
|
_lastMaintenanceDate = DateTime.now();
|
|
}
|
|
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
|
provider.loadBrands();
|
|
provider.loadModels();
|
|
if (widget.equipment != null) {
|
|
if (_selectedBrand != null && _selectedBrand!.isNotEmpty) {
|
|
_loadFilteredModels(_selectedBrand!);
|
|
}
|
|
if (_selectedCategory != null) {
|
|
_loadFilteredSubCategories(_selectedCategory!);
|
|
}
|
|
}
|
|
});
|
|
|
|
if (widget.equipment != null) {
|
|
_populateFields();
|
|
} else {
|
|
// Set up listeners for auto-generation of ID
|
|
_brandController.addListener(_triggerCandidateIdsUpdate);
|
|
_modelController.addListener(_triggerCandidateIdsUpdate);
|
|
_quantityToAddController.addListener(_triggerCandidateIdsUpdate);
|
|
_identifierController.addListener(_onIdentifierManualChanged);
|
|
|
|
// Run initial check once the page is fully mounted/built
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_triggerCandidateIdsUpdate();
|
|
});
|
|
}
|
|
}
|
|
|
|
void _populateFields() {
|
|
final equipment = widget.equipment!;
|
|
setState(() {
|
|
_identifierController.text = equipment.id;
|
|
_brandController.text = equipment.brand ?? '';
|
|
_selectedBrand = equipment.brand;
|
|
_modelController.text = equipment.model ?? '';
|
|
_subCategoryController.text = equipment.subCategory ?? '';
|
|
_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 ?? '';
|
|
|
|
_weightController.text = equipment.weight?.toString() ?? '';
|
|
_lengthController.text = equipment.length?.toString() ?? '';
|
|
_widthController.text = equipment.width?.toString() ?? '';
|
|
_heightController.text = equipment.height?.toString() ?? '';
|
|
});
|
|
|
|
// Disable auto-generation for editing
|
|
_autoGenerateIdNotifier.value = false;
|
|
|
|
DebugLog.info('[EquipmentForm] Populating fields for equipment: ${equipment.id}');
|
|
}
|
|
|
|
void _onIdentifierManualChanged() {
|
|
if (!_autoGenerateIdNotifier.value && widget.equipment == null) {
|
|
_triggerCandidateIdsUpdate();
|
|
}
|
|
}
|
|
|
|
void _triggerCandidateIdsUpdate() {
|
|
_idCheckDebouncer(() async {
|
|
if (!mounted || widget.equipment != null) return;
|
|
|
|
setState(() {
|
|
_isCalculatingIds = true;
|
|
});
|
|
|
|
try {
|
|
final ids = await _calculateCandidateIds();
|
|
if (!mounted) return;
|
|
|
|
setState(() {
|
|
_candidateIds = ids;
|
|
|
|
// If auto-generating, update the text field with the first generated ID
|
|
if (_autoGenerateIdNotifier.value && ids.isNotEmpty) {
|
|
_identifierController.removeListener(_onIdentifierManualChanged);
|
|
_identifierController.text = ids.first;
|
|
_identifierController.addListener(_onIdentifierManualChanged);
|
|
}
|
|
|
|
// Determine if there was an ID replacement/conflict
|
|
_idConflictMessage = null;
|
|
if (ids.isNotEmpty) {
|
|
final brand = _brandController.text.trim();
|
|
final model = _modelController.text.trim();
|
|
final quantityText = _quantityToAddController.text.trim();
|
|
final numbers = _parseQuantityOrRange(quantityText);
|
|
|
|
if (_autoGenerateIdNotifier.value) {
|
|
if (numbers != null && numbers.isNotEmpty) {
|
|
final firstExpectedNum = numbers.first;
|
|
final baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
|
|
final expectedFirstId = '${baseId}_#$firstExpectedNum';
|
|
if (ids.first != expectedFirstId) {
|
|
_idConflictMessage = "L'ID $expectedFirstId était déjà pris et a été remplacé par ${ids.first}";
|
|
}
|
|
}
|
|
} else {
|
|
final manualId = _identifierController.text.trim().toUpperCase();
|
|
if (manualId.isNotEmpty && ids.first != manualId) {
|
|
_idConflictMessage = "L'ID $manualId était déjà pris et a été remplacé par ${ids.first}";
|
|
}
|
|
}
|
|
}
|
|
});
|
|
} catch (e) {
|
|
DebugLog.error("Error calculating candidate IDs: $e");
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isCalculatingIds = false;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
List<int>? _parseQuantityOrRange(String text) {
|
|
text = text.trim();
|
|
if (text.isEmpty) return null;
|
|
|
|
// Try single number
|
|
final singleNum = int.tryParse(text);
|
|
if (singleNum != null) {
|
|
if (singleNum < 1 || singleNum > 100) return null;
|
|
return List<int>.generate(singleNum, (i) => i + 1);
|
|
}
|
|
|
|
// Try range pattern like "3-6" or "3 - 6"
|
|
final rangeRegex = RegExp(r'^(\d+)\s*-\s*(\d+)$');
|
|
final match = rangeRegex.firstMatch(text);
|
|
if (match != null) {
|
|
final start = int.tryParse(match.group(1)!);
|
|
final end = int.tryParse(match.group(2)!);
|
|
if (start != null && end != null && start > 0 && end >= start) {
|
|
if (end - start + 1 > 100) return null;
|
|
return List<int>.generate(end - start + 1, (i) => start + i);
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
IdParseResult _parseBaseAndNumber(String id) {
|
|
final match = RegExp(r'^(.*)_#(\d+)$').firstMatch(id);
|
|
if (match != null) {
|
|
return IdParseResult(match.group(1)!, int.parse(match.group(2)!));
|
|
}
|
|
return IdParseResult(id, null);
|
|
}
|
|
|
|
Future<List<String>> _calculateCandidateIds() async {
|
|
final brand = _brandController.text.trim();
|
|
final model = _modelController.text.trim();
|
|
final quantityText = _quantityToAddController.text.trim();
|
|
|
|
if (_autoGenerateIdNotifier.value && brand.isEmpty && model.isEmpty) {
|
|
return [];
|
|
}
|
|
|
|
// Get base ID
|
|
String baseId;
|
|
int? initialNumber;
|
|
|
|
if (_autoGenerateIdNotifier.value) {
|
|
baseId = IdGenerator.generateEquipmentId(brand: brand, model: model, number: null);
|
|
} else {
|
|
final manualId = _identifierController.text.trim().toUpperCase();
|
|
if (manualId.isEmpty) return [];
|
|
|
|
final parsed = _parseBaseAndNumber(manualId);
|
|
baseId = parsed.baseId;
|
|
initialNumber = parsed.number;
|
|
}
|
|
|
|
// Parse numbers
|
|
final numbers = _parseQuantityOrRange(quantityText);
|
|
if (numbers == null || numbers.isEmpty) return [];
|
|
|
|
// If the quantityText is just a single number (e.g. "5") and we have a manual ID with an initial number (e.g. "CUSTOM_#3"),
|
|
// we adjust the numbers to start from that initial number.
|
|
List<int> targetNumbers = numbers;
|
|
final isSingleNumberInput = int.tryParse(quantityText) != null;
|
|
if (isSingleNumberInput && initialNumber != null) {
|
|
targetNumbers = List<int>.generate(numbers.length, (i) => initialNumber! + i);
|
|
}
|
|
|
|
List<String> resultIds = [];
|
|
final Set<String> allocatedInBatch = {};
|
|
|
|
for (final num in targetNumbers) {
|
|
int currentNum = num;
|
|
String candidateId = '${baseId}_#$currentNum';
|
|
|
|
while (allocatedInBatch.contains(candidateId) || !(await _equipmentService.isIdUnique(candidateId))) {
|
|
currentNum++;
|
|
candidateId = '${baseId}_#$currentNum';
|
|
}
|
|
|
|
resultIds.add(candidateId);
|
|
allocatedInBatch.add(candidateId);
|
|
}
|
|
|
|
return resultIds;
|
|
}
|
|
|
|
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 = [];
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _loadFilteredSubCategories(EquipmentCategory category) async {
|
|
try {
|
|
final equipmentProvider = Provider.of<EquipmentProvider>(context, listen: false);
|
|
final subCategories = await equipmentProvider.loadSubCategoriesByCategory(category);
|
|
setState(() {
|
|
_filteredSubCategories = subCategories;
|
|
});
|
|
} catch (e) {
|
|
setState(() {
|
|
_filteredSubCategories = [];
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_brandController.removeListener(_triggerCandidateIdsUpdate);
|
|
_modelController.removeListener(_triggerCandidateIdsUpdate);
|
|
_quantityToAddController.removeListener(_triggerCandidateIdsUpdate);
|
|
_identifierController.removeListener(_onIdentifierManualChanged);
|
|
|
|
_identifierController.dispose();
|
|
_brandController.dispose();
|
|
_modelController.dispose();
|
|
_subCategoryController.dispose();
|
|
_purchasePriceController.dispose();
|
|
_rentalPriceController.dispose();
|
|
_totalQuantityController.dispose();
|
|
_criticalThresholdController.dispose();
|
|
_notesController.dispose();
|
|
_quantityToAddController.dispose();
|
|
|
|
_weightController.dispose();
|
|
_lengthController.dispose();
|
|
_widthController.dispose();
|
|
_heightController.dispose();
|
|
|
|
_autoGenerateIdNotifier.dispose();
|
|
_idCheckDebouncer.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
bool get _isConsumable => _selectedCategory != null && (_selectedCategory == EquipmentCategory.consumable || _selectedCategory == EquipmentCategory.cable);
|
|
|
|
Widget _buildCard({
|
|
required String title,
|
|
required IconData icon,
|
|
required List<Widget> children,
|
|
}) {
|
|
return Card(
|
|
elevation: 2,
|
|
shadowColor: Colors.black12,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
side: BorderSide(color: Colors.grey.shade200, width: 1),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(icon, color: AppColors.rouge, size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
title,
|
|
style: const TextStyle(
|
|
fontSize: 16,
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.noir,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const Divider(height: 24, thickness: 1),
|
|
...children,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
@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())
|
|
: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 800),
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24.0),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Card 1: Informations Générales
|
|
_buildCard(
|
|
title: 'Informations générales',
|
|
icon: Icons.info_outline,
|
|
children: [
|
|
// ID row with padlock and warning
|
|
ValueListenableBuilder<bool>(
|
|
valueListenable: _autoGenerateIdNotifier,
|
|
builder: (context, autoGenerateId, child) {
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _identifierController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Identifiant *',
|
|
border: const OutlineInputBorder(),
|
|
prefixIcon: const Icon(Icons.tag),
|
|
hintText: isEditing ? null : 'Généré automatiquement',
|
|
helperText: isEditing ? 'Non modifiable' : 'Identifiant unique du matériel',
|
|
),
|
|
enabled: !autoGenerateId && !isEditing,
|
|
validator: (value) {
|
|
if (value == null || value.isEmpty) {
|
|
return 'Veuillez entrer un identifiant';
|
|
}
|
|
if (value.toUpperCase().startsWith('BOX_')) {
|
|
return 'Les ID commençant par BOX_ sont réservés aux boites';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
),
|
|
if (!isEditing) ...[
|
|
const SizedBox(width: 8),
|
|
IconButton(
|
|
icon: Icon(
|
|
autoGenerateId ? Icons.lock : Icons.lock_open,
|
|
color: autoGenerateId ? AppColors.rouge : Colors.grey,
|
|
),
|
|
tooltip: autoGenerateId
|
|
? 'Génération automatique'
|
|
: 'Saisie manuelle',
|
|
onPressed: () {
|
|
_autoGenerateIdNotifier.value = !autoGenerateId;
|
|
if (_autoGenerateIdNotifier.value) {
|
|
_triggerCandidateIdsUpdate();
|
|
}
|
|
},
|
|
),
|
|
],
|
|
],
|
|
),
|
|
if (_idConflictMessage != null && !isEditing) ...[
|
|
const SizedBox(height: 6),
|
|
Row(
|
|
children: [
|
|
Icon(Icons.warning_amber_rounded, color: Colors.orange.shade800, size: 16),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(
|
|
_idConflictMessage!,
|
|
style: TextStyle(
|
|
color: Colors.orange.shade800,
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 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(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Expanded(
|
|
child: DropdownButtonFormField<EquipmentCategory>(
|
|
initialValue: _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(),
|
|
validator: (value) {
|
|
if (value == null) {
|
|
return 'Catégorie obligatoire';
|
|
}
|
|
return null;
|
|
},
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
_selectedCategory = value;
|
|
_subCategoryController.clear();
|
|
});
|
|
_loadFilteredSubCategories(value);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
if (!_isConsumable) ...[
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: DropdownButtonFormField<EquipmentStatus>(
|
|
initialValue: _selectedStatus,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Statut *',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.info_outline),
|
|
),
|
|
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),
|
|
|
|
// Sous-catégorie
|
|
SubCategorySelector(
|
|
controller: _subCategoryController,
|
|
selectedCategory: _selectedCategory,
|
|
filteredSubCategories: _filteredSubCategories,
|
|
onChanged: (value) {
|
|
setState(() {});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Card 2: Quantité & Stock
|
|
if (!isEditing || _isConsumable) ...[
|
|
_buildCard(
|
|
title: 'Quantité & Stock',
|
|
icon: Icons.inventory_2_outlined,
|
|
children: [
|
|
if (!isEditing) ...[
|
|
TextFormField(
|
|
controller: _quantityToAddController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nombre d\'exemplaires à créer *',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.copy),
|
|
helperText: 'Exemples de valeurs acceptées :\n- "5" : crée 5 exemplaires (de #1 à #5)\n- "3-6" : crée 4 exemplaires (de #3 à #6)',
|
|
helperMaxLines: 3,
|
|
),
|
|
keyboardType: TextInputType.text,
|
|
inputFormatters: [
|
|
FilteringTextInputFormatter.allow(RegExp(r'[0-9\s-]')),
|
|
],
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Veuillez entrer une quantité ou une plage';
|
|
}
|
|
final parsed = _parseQuantityOrRange(value);
|
|
if (parsed == null || parsed.isEmpty) {
|
|
return 'Format invalide (ex: "5" ou "3-6")';
|
|
}
|
|
if (parsed.length > 100) {
|
|
return 'La quantité maximale autorisée est de 100 exemplaires';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
if (_candidateIds.isNotEmpty) ...[
|
|
const SizedBox(height: 16),
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade50,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey.shade200),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(
|
|
'Identifiants qui seront créés (${_candidateIds.length}) :',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey.shade700,
|
|
),
|
|
),
|
|
if (_isCalculatingIds)
|
|
const SizedBox(
|
|
width: 12,
|
|
height: 12,
|
|
child: CircularProgressIndicator(strokeWidth: 1.5),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _candidateIds.map((id) {
|
|
return Chip(
|
|
label: Text(
|
|
id,
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
|
|
),
|
|
backgroundColor: AppColors.rouge.withValues(alpha: 0.08),
|
|
side: BorderSide(color: AppColors.rouge.withValues(alpha: 0.2)),
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
);
|
|
}).toList(),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
if (_isConsumable) const SizedBox(height: 16),
|
|
],
|
|
if (_isConsumable) ...[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _totalQuantityController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Quantité totale',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.format_list_numbered),
|
|
),
|
|
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_amber),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
|
|
// Card 3: Informations Financières
|
|
if (hasManagePermission) ...[
|
|
_buildCard(
|
|
title: 'Informations financières',
|
|
icon: Icons.euro_outlined,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _purchasePriceController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Prix d\'achat (€)',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.shopping_bag_outlined),
|
|
),
|
|
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.sell_outlined),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^\d+\.?\d{0,2}'))],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
],
|
|
|
|
// Card 4: Caractéristiques physiques (Amélioration)
|
|
_buildCard(
|
|
title: 'Caractéristiques physiques',
|
|
icon: Icons.scale_outlined,
|
|
children: [
|
|
TextFormField(
|
|
controller: _weightController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Poids à vide (kg)',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.scale),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _lengthController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Longueur (cm)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _widthController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Largeur (cm)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: TextFormField(
|
|
controller: _heightController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Hauteur (cm)',
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Card 5: Dates
|
|
_buildCard(
|
|
title: 'Dates & Maintenance',
|
|
icon: Icons.calendar_today_outlined,
|
|
children: [
|
|
_buildDateField(label: 'Date d\'achat', icon: Icons.shopping_cart_outlined, value: _purchaseDate, onTap: () => _selectDate(context, 'purchase')),
|
|
const SizedBox(height: 16),
|
|
_buildDateField(label: 'Dernière maintenance', icon: Icons.build_outlined, value: _lastMaintenanceDate, onTap: () => _selectDate(context, 'lastMaintenance')),
|
|
const SizedBox(height: 16),
|
|
_buildDateField(label: 'Prochaine maintenance', icon: Icons.event_outlined, value: _nextMaintenanceDate, onTap: () => _selectDate(context, 'nextMaintenance')),
|
|
],
|
|
),
|
|
const SizedBox(height: 20),
|
|
|
|
// Card 6: Notes
|
|
_buildCard(
|
|
title: 'Notes & Remarques',
|
|
icon: Icons.notes_outlined,
|
|
children: [
|
|
TextFormField(
|
|
controller: _notesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes complémentaires',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.edit_note),
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 32),
|
|
|
|
// Actions
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.end,
|
|
children: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler', style: TextStyle(fontSize: 16)),
|
|
),
|
|
const SizedBox(width: 16),
|
|
ElevatedButton(
|
|
onPressed: _saveEquipment,
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
),
|
|
child: Text(
|
|
isEditing ? 'Enregistrer' : 'Créer',
|
|
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold)
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 24),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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;
|
|
|
|
final scaffoldMessenger = ScaffoldMessenger.of(context);
|
|
final navigator = Navigator.of(context);
|
|
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
|
|
final brand = _brandController.text.trim();
|
|
final model = _modelController.text.trim();
|
|
|
|
if (brand.isEmpty || model.isEmpty) {
|
|
scaffoldMessenger.showSnackBar(
|
|
const SnackBar(content: Text('La marque et le modèle sont obligatoires')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Génération d'identifiant
|
|
List<String> ids = [];
|
|
if (!isEditing) {
|
|
ids = await _calculateCandidateIds();
|
|
if (ids.isEmpty) {
|
|
scaffoldMessenger.showSnackBar(
|
|
const SnackBar(content: Text('Impossible de générer des identifiants valides')),
|
|
);
|
|
setState(() => _isLoading = false);
|
|
return;
|
|
}
|
|
} else {
|
|
ids.add(_identifierController.text.trim().toUpperCase());
|
|
}
|
|
|
|
// Création/Mise à jour 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!,
|
|
subCategory: _subCategoryController.text.trim().isNotEmpty ? _subCategoryController.text.trim() : null,
|
|
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,
|
|
notes: _notesController.text,
|
|
createdAt: isEditing ? (widget.equipment?.createdAt ?? now) : now,
|
|
updatedAt: now,
|
|
availableQuantity: availableQuantity,
|
|
weight: _weightController.text.isNotEmpty ? double.tryParse(_weightController.text) : null,
|
|
length: _lengthController.text.isNotEmpty ? double.tryParse(_lengthController.text) : null,
|
|
width: _widthController.text.isNotEmpty ? double.tryParse(_widthController.text) : null,
|
|
height: _heightController.text.isNotEmpty ? double.tryParse(_heightController.text) : null,
|
|
);
|
|
if (isEditing) {
|
|
await equipmentProvider.updateEquipment(equipment);
|
|
} else {
|
|
await equipmentProvider.addEquipment(equipment);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
navigator.pop(true);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(content: Text('Erreur lors de l\'enregistrement : $e')),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
class IdParseResult {
|
|
final String baseId;
|
|
final int? number;
|
|
IdParseResult(this.baseId, this.number);
|
|
}
|