620 lines
20 KiB
Dart
620 lines
20 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:em2rp/models/maintenance_model.dart';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/providers/maintenance_provider.dart';
|
|
import 'package:em2rp/providers/equipment_provider.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:em2rp/utils/colors.dart';
|
|
import 'package:uuid/uuid.dart';
|
|
|
|
/// Page de formulaire pour créer ou modifier une maintenance
|
|
class MaintenanceFormPage extends StatefulWidget {
|
|
final MaintenanceModel? maintenance;
|
|
final List<String>? initialEquipmentIds;
|
|
|
|
const MaintenanceFormPage({
|
|
super.key,
|
|
this.maintenance,
|
|
this.initialEquipmentIds,
|
|
});
|
|
|
|
@override
|
|
State<MaintenanceFormPage> createState() => _MaintenanceFormPageState();
|
|
}
|
|
|
|
class _MaintenanceFormPageState extends State<MaintenanceFormPage> {
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _nameController = TextEditingController();
|
|
final _descriptionController = TextEditingController();
|
|
final _costController = TextEditingController();
|
|
final _notesController = TextEditingController();
|
|
|
|
MaintenanceType _selectedType = MaintenanceType.preventive;
|
|
DateTime _scheduledDate = DateTime.now();
|
|
final List<String> _selectedEquipmentIds = [];
|
|
bool _isLoading = false;
|
|
|
|
bool get _isEditing => widget.maintenance != null;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
if (_isEditing) {
|
|
_nameController.text = widget.maintenance!.name;
|
|
_descriptionController.text = widget.maintenance!.description;
|
|
_selectedType = widget.maintenance!.type;
|
|
_scheduledDate = widget.maintenance!.scheduledDate;
|
|
_selectedEquipmentIds.addAll(widget.maintenance!.equipmentIds);
|
|
|
|
if (widget.maintenance!.cost != null) {
|
|
_costController.text = widget.maintenance!.cost!.toStringAsFixed(2);
|
|
}
|
|
if (widget.maintenance!.notes != null) {
|
|
_notesController.text = widget.maintenance!.notes!;
|
|
}
|
|
} else if (widget.initialEquipmentIds != null) {
|
|
// Pré-remplir avec les équipements fournis
|
|
_selectedEquipmentIds.addAll(widget.initialEquipmentIds!);
|
|
}
|
|
|
|
// Charger les équipements
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<EquipmentProvider>().ensureLoaded();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_nameController.dispose();
|
|
_descriptionController.dispose();
|
|
_costController.dispose();
|
|
_notesController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(_isEditing ? 'Modifier la maintenance' : 'Nouvelle maintenance'),
|
|
backgroundColor: AppColors.bleuFonce,
|
|
),
|
|
body: Form(
|
|
key: _formKey,
|
|
child: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
// Nom
|
|
TextFormField(
|
|
controller: _nameController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Nom de la maintenance *',
|
|
hintText: 'Ex: Révision annuelle',
|
|
prefixIcon: Icon(Icons.title),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Le nom est requis';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Type
|
|
DropdownButtonFormField<MaintenanceType>(
|
|
initialValue: _selectedType,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Type de maintenance *',
|
|
prefixIcon: Icon(Icons.category),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
items: MaintenanceType.values.map((type) {
|
|
final info = _getMaintenanceTypeInfo(type);
|
|
return DropdownMenuItem(
|
|
value: type,
|
|
child: Row(
|
|
children: [
|
|
Icon(info.$2, size: 20, color: info.$3),
|
|
const SizedBox(width: 8),
|
|
Text(info.$1),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (value) {
|
|
if (value != null) {
|
|
setState(() {
|
|
_selectedType = value;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Date planifiée
|
|
InkWell(
|
|
onTap: _selectDate,
|
|
child: InputDecorator(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Date planifiée *',
|
|
prefixIcon: Icon(Icons.event),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text(DateFormat('dd/MM/yyyy').format(_scheduledDate)),
|
|
const Icon(Icons.arrow_drop_down),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Équipements
|
|
_buildEquipmentSelector(),
|
|
const SizedBox(height: 16),
|
|
|
|
// Description
|
|
TextFormField(
|
|
controller: _descriptionController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Description *',
|
|
hintText: 'Détails de l\'opération à effectuer',
|
|
prefixIcon: Icon(Icons.description),
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 4,
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'La description est requise';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Coût estimé
|
|
TextFormField(
|
|
controller: _costController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Coût estimé (€)',
|
|
hintText: 'Ex: 150.00',
|
|
prefixIcon: Icon(Icons.euro),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
keyboardType: TextInputType.number,
|
|
validator: (value) {
|
|
if (value != null && value.isNotEmpty) {
|
|
if (double.tryParse(value) == null) {
|
|
return 'Coût invalide';
|
|
}
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Notes
|
|
TextFormField(
|
|
controller: _notesController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Notes',
|
|
hintText: 'Informations complémentaires',
|
|
prefixIcon: Icon(Icons.notes),
|
|
border: OutlineInputBorder(),
|
|
alignLabelWithHint: true,
|
|
),
|
|
maxLines: 3,
|
|
),
|
|
const SizedBox(height: 24),
|
|
|
|
// Bouton sauvegarder
|
|
ElevatedButton.icon(
|
|
onPressed: _isLoading ? null : _saveMaintenance,
|
|
icon: _isLoading
|
|
? const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
|
)
|
|
: const Icon(Icons.save),
|
|
label: Text(_isEditing ? 'Mettre à jour' : 'Créer la maintenance'),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.bleuFonce,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildEquipmentSelector() {
|
|
return Consumer<EquipmentProvider>(
|
|
builder: (context, equipmentProvider, _) {
|
|
// Filtrer uniquement les équipements
|
|
final availableEquipment = equipmentProvider.allEquipment
|
|
.cast<EquipmentModel>()
|
|
.toList();
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Équipements concernés *',
|
|
prefixIcon: const Icon(Icons.inventory),
|
|
border: const OutlineInputBorder(),
|
|
errorText: _selectedEquipmentIds.isEmpty ? 'Sélectionnez au moins un équipement' : null,
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (_selectedEquipmentIds.isEmpty)
|
|
const Text(
|
|
'Aucun équipement sélectionné',
|
|
style: TextStyle(color: Colors.grey),
|
|
)
|
|
else
|
|
Wrap(
|
|
spacing: 8,
|
|
runSpacing: 8,
|
|
children: _selectedEquipmentIds.map((id) {
|
|
final equipment = availableEquipment.firstWhere(
|
|
(eq) => eq.id == id,
|
|
orElse: () => EquipmentModel(
|
|
id: id,
|
|
name: 'Inconnu',
|
|
category: EquipmentCategory.other,
|
|
status: EquipmentStatus.available,
|
|
maintenanceIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
return Chip(
|
|
label: Text(equipment.name),
|
|
deleteIcon: const Icon(Icons.close, size: 18),
|
|
onDeleted: () {
|
|
setState(() {
|
|
_selectedEquipmentIds.remove(id);
|
|
});
|
|
},
|
|
);
|
|
}).toList(),
|
|
),
|
|
const SizedBox(height: 8),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _showEquipmentPicker(availableEquipment),
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Ajouter des équipements'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _showEquipmentPicker(List<EquipmentModel> availableEquipment) async {
|
|
final selectedIds = await showDialog<List<String>>(
|
|
context: context,
|
|
builder: (context) => _EquipmentPickerDialog(
|
|
availableEquipment: availableEquipment,
|
|
initialSelectedIds: _selectedEquipmentIds,
|
|
),
|
|
);
|
|
|
|
if (selectedIds != null) {
|
|
setState(() {
|
|
_selectedEquipmentIds.clear();
|
|
_selectedEquipmentIds.addAll(selectedIds);
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _selectDate() async {
|
|
final date = await showDatePicker(
|
|
context: context,
|
|
initialDate: _scheduledDate,
|
|
firstDate: DateTime.now().subtract(const Duration(days: 365)),
|
|
lastDate: DateTime.now().add(const Duration(days: 365 * 5)),
|
|
locale: const Locale('fr', 'FR'),
|
|
);
|
|
|
|
if (date != null) {
|
|
setState(() {
|
|
_scheduledDate = date;
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _saveMaintenance() async {
|
|
if (!_formKey.currentState!.validate()) {
|
|
return;
|
|
}
|
|
|
|
if (_selectedEquipmentIds.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Veuillez sélectionner au moins un équipement'),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
return;
|
|
}
|
|
|
|
setState(() {
|
|
_isLoading = true;
|
|
});
|
|
|
|
try {
|
|
final cost = _costController.text.trim().isNotEmpty
|
|
? double.tryParse(_costController.text.trim())
|
|
: null;
|
|
|
|
final notes = _notesController.text.trim().isNotEmpty
|
|
? _notesController.text.trim()
|
|
: null;
|
|
|
|
if (_isEditing) {
|
|
// Mise à jour
|
|
await context.read<MaintenanceProvider>().updateMaintenance(
|
|
widget.maintenance!.id,
|
|
{
|
|
'name': _nameController.text.trim(),
|
|
'description': _descriptionController.text.trim(),
|
|
'type': maintenanceTypeToString(_selectedType),
|
|
'scheduledDate': _scheduledDate,
|
|
'equipmentIds': _selectedEquipmentIds,
|
|
'cost': cost,
|
|
'notes': notes,
|
|
'updatedAt': DateTime.now(),
|
|
},
|
|
);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Maintenance mise à jour avec succès'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
} else {
|
|
// Création
|
|
final maintenance = MaintenanceModel(
|
|
id: const Uuid().v4(),
|
|
equipmentIds: _selectedEquipmentIds,
|
|
type: _selectedType,
|
|
scheduledDate: _scheduledDate,
|
|
name: _nameController.text.trim(),
|
|
description: _descriptionController.text.trim(),
|
|
cost: cost,
|
|
notes: notes,
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
|
|
await context.read<MaintenanceProvider>().createMaintenance(maintenance);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Maintenance créée avec succès'),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
if (mounted) {
|
|
Navigator.pop(context, true);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur: $e'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
} finally {
|
|
if (mounted) {
|
|
setState(() {
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
(String, IconData, Color) _getMaintenanceTypeInfo(MaintenanceType type) {
|
|
switch (type) {
|
|
case MaintenanceType.preventive:
|
|
return ('Préventive', Icons.schedule, Colors.blue);
|
|
case MaintenanceType.corrective:
|
|
return ('Corrective', Icons.build, Colors.orange);
|
|
case MaintenanceType.inspection:
|
|
return ('Inspection', Icons.search, Colors.purple);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Dialog pour sélectionner plusieurs équipements
|
|
class _EquipmentPickerDialog extends StatefulWidget {
|
|
final List<EquipmentModel> availableEquipment;
|
|
final List<String> initialSelectedIds;
|
|
|
|
const _EquipmentPickerDialog({
|
|
required this.availableEquipment,
|
|
required this.initialSelectedIds,
|
|
});
|
|
|
|
@override
|
|
State<_EquipmentPickerDialog> createState() => _EquipmentPickerDialogState();
|
|
}
|
|
|
|
class _EquipmentPickerDialogState extends State<_EquipmentPickerDialog> {
|
|
late List<String> _selectedIds;
|
|
String _searchQuery = '';
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_selectedIds = List.from(widget.initialSelectedIds);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final filteredEquipment = widget.availableEquipment.where((eq) {
|
|
if (_searchQuery.isEmpty) return true;
|
|
return eq.name.toLowerCase().contains(_searchQuery.toLowerCase()) ||
|
|
eq.id.toLowerCase().contains(_searchQuery.toLowerCase());
|
|
}).toList();
|
|
|
|
return AlertDialog(
|
|
title: const Text('Sélectionner des équipements'),
|
|
content: SizedBox(
|
|
width: double.maxFinite,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Barre de recherche
|
|
TextField(
|
|
decoration: const InputDecoration(
|
|
labelText: 'Rechercher',
|
|
prefixIcon: Icon(Icons.search),
|
|
border: OutlineInputBorder(),
|
|
),
|
|
onChanged: (value) {
|
|
setState(() {
|
|
_searchQuery = value;
|
|
});
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Compteur
|
|
Text(
|
|
'${_selectedIds.length} équipement(s) sélectionné(s)',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 8),
|
|
|
|
// Liste des équipements
|
|
Expanded(
|
|
child: filteredEquipment.isEmpty
|
|
? const Center(child: Text('Aucun équipement trouvé'))
|
|
: ListView.builder(
|
|
shrinkWrap: true,
|
|
itemCount: filteredEquipment.length,
|
|
itemBuilder: (context, index) {
|
|
final equipment = filteredEquipment[index];
|
|
final isSelected = _selectedIds.contains(equipment.id);
|
|
|
|
return CheckboxListTile(
|
|
value: isSelected,
|
|
onChanged: (selected) {
|
|
setState(() {
|
|
if (selected == true) {
|
|
_selectedIds.add(equipment.id);
|
|
} else {
|
|
_selectedIds.remove(equipment.id);
|
|
}
|
|
});
|
|
},
|
|
title: Text(equipment.name),
|
|
subtitle: Text(
|
|
'${equipment.id} • ${_getCategoryLabel(equipment.category)}',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
|
),
|
|
secondary: Icon(
|
|
_getCategoryIcon(equipment.category),
|
|
color: AppColors.bleuFonce,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.pop(context, _selectedIds),
|
|
style: ElevatedButton.styleFrom(backgroundColor: AppColors.bleuFonce),
|
|
child: const Text('Valider'),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _getCategoryLabel(EquipmentCategory category) {
|
|
switch (category) {
|
|
case EquipmentCategory.sound:
|
|
return 'Son';
|
|
case EquipmentCategory.lighting:
|
|
return 'Lumière';
|
|
case EquipmentCategory.video:
|
|
return 'Vidéo';
|
|
case EquipmentCategory.structure:
|
|
return 'Structure';
|
|
case EquipmentCategory.effect:
|
|
return 'Effets';
|
|
case EquipmentCategory.cable:
|
|
return 'Câblage';
|
|
case EquipmentCategory.consumable:
|
|
return 'Consommable';
|
|
case EquipmentCategory.vehicle:
|
|
return 'Véhicule';
|
|
case EquipmentCategory.backline:
|
|
return 'Backline';
|
|
case EquipmentCategory.other:
|
|
return 'Autre';
|
|
}
|
|
}
|
|
|
|
IconData _getCategoryIcon(EquipmentCategory category) {
|
|
switch (category) {
|
|
case EquipmentCategory.sound:
|
|
return Icons.volume_up;
|
|
case EquipmentCategory.lighting:
|
|
return Icons.lightbulb;
|
|
case EquipmentCategory.video:
|
|
return Icons.videocam;
|
|
case EquipmentCategory.structure:
|
|
return Icons.construction;
|
|
case EquipmentCategory.effect:
|
|
return Icons.auto_awesome;
|
|
case EquipmentCategory.cable:
|
|
return Icons.cable;
|
|
case EquipmentCategory.consumable:
|
|
return Icons.inventory_2;
|
|
case EquipmentCategory.vehicle:
|
|
return Icons.local_shipping;
|
|
case EquipmentCategory.backline:
|
|
return Icons.queue_music;
|
|
case EquipmentCategory.other:
|
|
return Icons.category;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|