eac103491f
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
- Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
- Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
- Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
- Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
- Refonte de l'interface mobile pour intégrer la barre de recherche.
- Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
- Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
- Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
- Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
1274 lines
44 KiB
Dart
1274 lines
44 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:em2rp/utils/colors.dart';
|
|
import 'package:em2rp/utils/permission_gate.dart';
|
|
import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
|
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
|
import 'package:em2rp/providers/equipment_provider.dart';
|
|
import 'package:em2rp/providers/container_provider.dart';
|
|
import 'package:em2rp/models/equipment_model.dart';
|
|
import 'package:em2rp/models/container_model.dart';
|
|
import 'package:em2rp/views/equipment_form_page.dart';
|
|
import 'package:em2rp/views/equipment_detail_page.dart';
|
|
import 'package:em2rp/views/container_detail_page.dart';
|
|
import 'package:em2rp/views/widgets/common/qr_code_dialog.dart';
|
|
import 'package:em2rp/views/widgets/common/qr_code_scanner_dialog.dart';
|
|
import 'package:em2rp/views/widgets/common/qr_code_format_selector_dialog.dart';
|
|
import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
|
import 'package:em2rp/utils/debug_log.dart';
|
|
import 'package:em2rp/utils/equipment_delete_utils.dart';
|
|
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
|
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
|
import 'package:em2rp/views/widgets/notification_badge.dart';
|
|
|
|
class EquipmentManagementPage extends StatefulWidget {
|
|
const EquipmentManagementPage({super.key});
|
|
|
|
@override
|
|
State<EquipmentManagementPage> createState() =>
|
|
_EquipmentManagementPageState();
|
|
}
|
|
|
|
class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
|
with SelectionModeMixin<EquipmentManagementPage> {
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
EquipmentCategory? _selectedCategory;
|
|
List<EquipmentModel>? _cachedEquipment;
|
|
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
DebugLog.info('[EquipmentManagementPage] initState called');
|
|
|
|
// Activer le mode pagination
|
|
final provider = context.read<EquipmentProvider>();
|
|
provider.enablePagination();
|
|
|
|
// Ajouter le listener de scroll pour le chargement infini
|
|
_scrollController.addListener(_onScroll);
|
|
|
|
// Charger la première page au démarrage
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
DebugLog.info('[EquipmentManagementPage] Loading first page...');
|
|
provider.loadFirstPage();
|
|
});
|
|
}
|
|
|
|
void _onScroll() {
|
|
// Éviter les appels multiples avec un flag simple (sans setState)
|
|
if (_isLoadingMore) return;
|
|
|
|
final provider = context.read<EquipmentProvider>();
|
|
|
|
// Charger la page suivante quand on arrive à 300px du bas
|
|
if (_scrollController.hasClients &&
|
|
_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent - 300) {
|
|
// Vérifier qu'on peut charger plus
|
|
if (provider.hasMore && !provider.isLoadingMore) {
|
|
// ✅ Pas de setState ici pour éviter les rebuilds pendant le scroll
|
|
_isLoadingMore = true;
|
|
|
|
provider.loadNextPage().then((_) {
|
|
_isLoadingMore = false;
|
|
}).catchError((error) {
|
|
_isLoadingMore = false;
|
|
DebugLog.error(
|
|
'[EquipmentManagementPage] Error loading next page', error);
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_scrollController.removeListener(_onScroll);
|
|
_scrollController.dispose();
|
|
_searchController.dispose();
|
|
// Désactiver le mode pagination en quittant
|
|
context.read<EquipmentProvider>().disablePagination();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isMobile = MediaQuery.of(context).size.width < 800;
|
|
|
|
return PermissionGate(
|
|
requiredPermissions: const ['view_equipment'],
|
|
fallback: Scaffold(
|
|
appBar: const CustomAppBar(title: 'Accès refusé'),
|
|
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
|
body: const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(24.0),
|
|
child: Text(
|
|
'Vous n\'avez pas les permissions nécessaires pour accéder à la gestion du matériel.',
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
child: Scaffold(
|
|
appBar: isSelectionMode
|
|
? AppBar(
|
|
backgroundColor: AppColors.rouge,
|
|
leading: IconButton(
|
|
icon: const Icon(Icons.close, color: Colors.white),
|
|
onPressed: toggleSelectionMode,
|
|
),
|
|
title: Text(
|
|
'$selectedCount sélectionné(s)',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
actions: [
|
|
const NotificationBadge(),
|
|
if (hasSelection) ...[
|
|
IconButton(
|
|
icon: const Icon(Icons.qr_code, color: Colors.white),
|
|
tooltip: 'Générer QR Codes',
|
|
onPressed: _generateQRCodesForSelected,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.delete, color: Colors.white),
|
|
tooltip: 'Supprimer',
|
|
onPressed: _deleteSelectedEquipment,
|
|
),
|
|
],
|
|
],
|
|
)
|
|
: CustomAppBar(
|
|
title: 'Gestion du matériel',
|
|
),
|
|
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
|
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
|
floatingActionButton: isSelectionMode ? null : _buildFAB(),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildFAB() {
|
|
return PermissionGate(
|
|
requiredPermissions: const ['manage_equipment'],
|
|
child: FloatingActionButton.extended(
|
|
onPressed: _createNewEquipment,
|
|
backgroundColor: AppColors.rouge,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Ajouter un équipement'),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildMobileLayout() {
|
|
return Column(
|
|
children: [
|
|
// Barre de recherche et boutons d'action
|
|
SearchActionsBar(
|
|
controller: _searchController,
|
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
|
onChanged: (value) {
|
|
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
},
|
|
onClear: () {
|
|
_searchController.clear();
|
|
context.read<EquipmentProvider>().setSearchQuery('');
|
|
},
|
|
actions: [
|
|
IconButton.filled(
|
|
onPressed: _scanQRCode,
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
tooltip: 'Scanner un QR Code',
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.grey[700],
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
IconButton.filled(
|
|
onPressed: () {
|
|
Navigator.pushNamed(context, '/container_management');
|
|
},
|
|
icon: const Icon(Icons.inventory_2),
|
|
tooltip: 'Gérer les boîtes',
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Menu horizontal de filtres par catégorie
|
|
SizedBox(
|
|
height: 60,
|
|
child: ListView(
|
|
scrollDirection: Axis.horizontal,
|
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: ChoiceChip(
|
|
label: const Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.all_inclusive, size: 16, color: Colors.white),
|
|
SizedBox(width: 8),
|
|
Text('Tout'),
|
|
],
|
|
),
|
|
selected: _selectedCategory == null,
|
|
onSelected: (selected) {
|
|
if (selected) {
|
|
setState(() => _selectedCategory = null);
|
|
context
|
|
.read<EquipmentProvider>()
|
|
.setSelectedCategory(null);
|
|
}
|
|
},
|
|
selectedColor: AppColors.rouge,
|
|
labelStyle: TextStyle(
|
|
color: _selectedCategory == null
|
|
? Colors.white
|
|
: AppColors.rouge,
|
|
fontWeight: _selectedCategory == null
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
),
|
|
..._buildCategoryChips(),
|
|
],
|
|
),
|
|
),
|
|
const Divider(),
|
|
// Liste des équipements
|
|
Expanded(child: _buildEquipmentList()),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildDesktopLayout() {
|
|
return Row(
|
|
children: [
|
|
// Sidebar gauche avec filtres
|
|
Container(
|
|
width: 280,
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
border: const Border(
|
|
right: BorderSide(color: Colors.grey, width: 1),
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
const SizedBox(height: 16),
|
|
// En-tête filtres
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
|
child: Row(
|
|
children: [
|
|
Icon(Icons.filter_list, color: AppColors.rouge, size: 20),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'Filtres',
|
|
style:
|
|
Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: AppColors.rouge,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Filtres par catégorie
|
|
Padding(
|
|
padding:
|
|
const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
|
child: Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Text(
|
|
'Catégories',
|
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
children: [
|
|
ListTile(
|
|
leading: Icon(
|
|
Icons.all_inclusive,
|
|
color: _selectedCategory == null
|
|
? AppColors.rouge
|
|
: Colors.grey[600],
|
|
),
|
|
title: Text(
|
|
'Tout',
|
|
style: TextStyle(
|
|
color: _selectedCategory == null
|
|
? AppColors.rouge
|
|
: Colors.black87,
|
|
fontWeight: _selectedCategory == null
|
|
? FontWeight.bold
|
|
: FontWeight.normal,
|
|
),
|
|
),
|
|
selected: _selectedCategory == null,
|
|
selectedTileColor: AppColors.rouge.withOpacity(0.1),
|
|
onTap: () {
|
|
setState(() => _selectedCategory = null);
|
|
context
|
|
.read<EquipmentProvider>()
|
|
.setSelectedCategory(null);
|
|
},
|
|
),
|
|
..._buildCategoryListTiles(),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Contenu principal
|
|
Expanded(
|
|
child: Column(
|
|
children: [
|
|
SearchActionsBar(
|
|
controller: _searchController,
|
|
hintText: 'Rechercher par nom, modèle ou ID...',
|
|
onChanged: (value) {
|
|
context.read<EquipmentProvider>().setSearchQuery(value);
|
|
},
|
|
onClear: () {
|
|
_searchController.clear();
|
|
context.read<EquipmentProvider>().setSearchQuery('');
|
|
},
|
|
actions: [
|
|
IconButton.filled(
|
|
onPressed: _scanQRCode,
|
|
icon: const Icon(Icons.qr_code_scanner),
|
|
tooltip: 'Scanner un QR Code',
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: Colors.grey[700],
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
IconButton.filled(
|
|
onPressed: () {
|
|
Navigator.pushNamed(context, '/container_management');
|
|
},
|
|
icon: const Icon(Icons.inventory_2),
|
|
tooltip: 'Gérer les boîtes',
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
if (!isSelectionMode)
|
|
IconButton.filled(
|
|
onPressed: toggleSelectionMode,
|
|
icon: const Icon(Icons.checklist),
|
|
tooltip: 'Mode sélection',
|
|
style: IconButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
foregroundColor: Colors.white,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
Expanded(child: _buildEquipmentList()),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
List<Widget> _buildCategoryChips() {
|
|
return EquipmentCategory.values.map((category) {
|
|
final isSelected = _selectedCategory == category;
|
|
final color = isSelected ? Colors.white : AppColors.rouge;
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
|
child: ChoiceChip(
|
|
label: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
category.getIcon(
|
|
size: 16,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(category.label),
|
|
],
|
|
),
|
|
selected: isSelected,
|
|
onSelected: (selected) {
|
|
if (selected) {
|
|
setState(() => _selectedCategory = category);
|
|
context.read<EquipmentProvider>().setSelectedCategory(category);
|
|
}
|
|
},
|
|
selectedColor: AppColors.rouge,
|
|
labelStyle: TextStyle(
|
|
color: color,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
List<Widget> _buildCategoryListTiles() {
|
|
return EquipmentCategory.values.map((category) {
|
|
final isSelected = _selectedCategory == category;
|
|
final color = isSelected ? AppColors.rouge : Colors.grey[600]!;
|
|
|
|
return ListTile(
|
|
leading: category.getIcon(
|
|
size: 24,
|
|
color: color,
|
|
),
|
|
title: Text(
|
|
category.label,
|
|
style: TextStyle(
|
|
color: isSelected ? AppColors.rouge : Colors.black87,
|
|
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
|
),
|
|
),
|
|
selected: isSelected,
|
|
selectedTileColor: AppColors.rouge.withOpacity(0.1),
|
|
onTap: () {
|
|
setState(() => _selectedCategory = category);
|
|
context.read<EquipmentProvider>().setSelectedCategory(category);
|
|
},
|
|
);
|
|
}).toList();
|
|
}
|
|
|
|
Widget _buildEquipmentList() {
|
|
return Consumer<EquipmentProvider>(
|
|
builder: (context, provider, child) {
|
|
DebugLog.info(
|
|
'[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
|
|
|
// Afficher l'indicateur de chargement initial uniquement
|
|
if (provider.isLoading && provider.equipment.isEmpty) {
|
|
DebugLog.info(
|
|
'[EquipmentManagementPage] Showing initial loading indicator');
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
final equipments = provider.equipment;
|
|
|
|
if (equipments.isEmpty && !provider.isLoading) {
|
|
DebugLog.info('[EquipmentManagementPage] No equipment found');
|
|
return Center(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(
|
|
Icons.inventory_2_outlined,
|
|
size: 64,
|
|
color: Colors.grey[400],
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'Aucun équipement trouvé',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
color: Colors.grey[600],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
DebugLog.info(
|
|
'[EquipmentManagementPage] Building list with ${equipments.length} items');
|
|
|
|
// Calculer le nombre total d'items (équipements + indicateur de chargement)
|
|
final itemCount = equipments.length + (provider.hasMore ? 1 : 0);
|
|
|
|
return ListView.builder(
|
|
controller: _scrollController,
|
|
itemCount: itemCount,
|
|
// ✅ Ajouter une estimation de la hauteur pour améliorer le scroll
|
|
// Note : À ajuster selon la hauteur réelle de vos cartes
|
|
// itemExtent: 140, // Décommentez si toutes les cartes ont la même hauteur
|
|
// ✅ Augmenter le cache pour un scroll plus fluide
|
|
cacheExtent: 500, // Précharger 500px en plus
|
|
itemBuilder: (context, index) {
|
|
// Dernier élément = indicateur de chargement
|
|
if (index == equipments.length) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(16.0),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
}
|
|
|
|
return _buildEquipmentCard(equipments[index]);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildEquipmentCard(EquipmentModel equipment) {
|
|
final isSelected = isItemSelected(equipment.id);
|
|
|
|
// ✅ RepaintBoundary pour isoler le repaint de chaque carte
|
|
return RepaintBoundary(
|
|
key: ValueKey(equipment.id),
|
|
child: Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
color: isSelectionMode && isSelected
|
|
? AppColors.rouge.withValues(alpha: 0.1)
|
|
: null,
|
|
child: ListTile(
|
|
leading: isSelectionMode
|
|
? Checkbox(
|
|
value: isSelected,
|
|
onChanged: (value) => toggleItemSelection(equipment.id),
|
|
activeColor: AppColors.rouge,
|
|
)
|
|
: CircleAvatar(
|
|
backgroundColor:
|
|
equipment.category.color.withValues(alpha: 0.2),
|
|
child: equipment.category.getIcon(
|
|
size: 20,
|
|
color: equipment.category.color,
|
|
),
|
|
),
|
|
title: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(
|
|
equipment.id,
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
// Afficher le badge de statut calculé dynamiquement
|
|
if (equipment.category != EquipmentCategory.consumable &&
|
|
equipment.category != EquipmentCategory.cable)
|
|
EquipmentStatusBadge(equipment: equipment),
|
|
],
|
|
),
|
|
subtitle: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
|
.trim()
|
|
.isNotEmpty
|
|
? '${equipment.brand ?? ''} ${equipment.model ?? ''}'
|
|
.trim()
|
|
: 'Marque/Modèle non défini',
|
|
style: TextStyle(color: Colors.grey[600], fontSize: 14),
|
|
),
|
|
// Afficher la sous-catégorie si elle existe
|
|
if (equipment.subCategory != null &&
|
|
equipment.subCategory!.isNotEmpty) ...[
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
'📁 ${equipment.subCategory}',
|
|
style: TextStyle(
|
|
color: Colors.grey[500],
|
|
fontSize: 12,
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
// Afficher la quantité disponible pour les consommables/câbles
|
|
if (equipment.category == EquipmentCategory.consumable ||
|
|
equipment.category == EquipmentCategory.cable) ...[
|
|
const SizedBox(height: 4),
|
|
_buildQuantityDisplay(equipment),
|
|
],
|
|
],
|
|
),
|
|
trailing: isSelectionMode
|
|
? null
|
|
: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Bouton Restock (uniquement pour consommables/câbles avec permission)
|
|
if (equipment.category == EquipmentCategory.consumable ||
|
|
equipment.category == EquipmentCategory.cable)
|
|
PermissionGate(
|
|
requiredPermissions: const ['manage_equipment'],
|
|
child: IconButton(
|
|
icon: const Icon(Icons.add_shopping_cart,
|
|
color: AppColors.rouge),
|
|
tooltip: 'Restock',
|
|
onPressed: () => _showRestockDialog(equipment),
|
|
),
|
|
),
|
|
// Bouton QR Code
|
|
IconButton(
|
|
icon: const Icon(Icons.qr_code, color: AppColors.rouge),
|
|
tooltip: 'QR Code',
|
|
onPressed: () => showDialog(
|
|
context: context,
|
|
builder: (context) =>
|
|
QRCodeDialog.forEquipment(equipment),
|
|
),
|
|
),
|
|
// Bouton Modifier (permission required)
|
|
PermissionGate(
|
|
requiredPermissions: const ['manage_equipment'],
|
|
child: IconButton(
|
|
icon: const Icon(Icons.edit, color: AppColors.rouge),
|
|
tooltip: 'Modifier',
|
|
onPressed: () => _editEquipment(equipment),
|
|
),
|
|
),
|
|
// Bouton Supprimer (permission required)
|
|
PermissionGate(
|
|
requiredPermissions: const ['manage_equipment'],
|
|
child: IconButton(
|
|
icon: const Icon(Icons.delete, color: Colors.red),
|
|
tooltip: 'Supprimer',
|
|
onPressed: () => _deleteEquipment(equipment),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
onTap: isSelectionMode
|
|
? () => toggleItemSelection(equipment.id)
|
|
: () => _viewEquipmentDetails(equipment),
|
|
),
|
|
));
|
|
}
|
|
|
|
Widget _buildQuantityDisplay(EquipmentModel equipment) {
|
|
final availableQty = equipment.availableQuantity ?? 0;
|
|
final totalQty = equipment.totalQuantity ?? 0;
|
|
final criticalThreshold = equipment.criticalThreshold ?? 0;
|
|
final isCritical =
|
|
criticalThreshold > 0 && availableQty <= criticalThreshold;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: isCritical
|
|
? Colors.red.withOpacity(0.15)
|
|
: Colors.grey.withOpacity(0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isCritical ? Colors.red : Colors.grey.shade400,
|
|
width: isCritical ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
isCritical ? Icons.warning : Icons.inventory,
|
|
size: 16,
|
|
color: isCritical ? Colors.red : Colors.grey[700],
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'Disponible: $availableQty / $totalQty',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: isCritical ? FontWeight.bold : FontWeight.normal,
|
|
color: isCritical ? Colors.red : Colors.grey[700],
|
|
),
|
|
),
|
|
if (isCritical) ...[
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: Colors.red,
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Text(
|
|
'CRITIQUE',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// Actions
|
|
void _createNewEquipment() {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => const EquipmentFormPage(),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _editEquipment(EquipmentModel equipment) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => EquipmentFormPage(equipment: equipment),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _deleteEquipment(EquipmentModel equipment) {
|
|
final pageContext = context;
|
|
final equipmentLabel = EquipmentDeleteUtils.resolveEquipmentLabel(
|
|
id: equipment.id,
|
|
name: equipment.name,
|
|
);
|
|
showDialog(
|
|
context: pageContext,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildSingleDeleteConfirmationMessage(
|
|
equipmentLabel,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(dialogContext);
|
|
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
|
final provider = pageContext.read<EquipmentProvider>();
|
|
|
|
try {
|
|
final deleted =
|
|
await EquipmentDeleteUtils.deleteWithFutureAssignmentCheck(
|
|
context: pageContext,
|
|
equipmentLabel: equipmentLabel,
|
|
deleteEquipment: ({bool forceDelete = false}) {
|
|
return provider.deleteEquipment(
|
|
equipment.id,
|
|
forceDelete: forceDelete,
|
|
);
|
|
},
|
|
);
|
|
if (!deleted) {
|
|
return;
|
|
}
|
|
scaffoldMessenger.showSnackBar(
|
|
const SnackBar(
|
|
content: Text(EquipmentDeleteUtils.deleteSuccessMessage),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _deleteSelectedEquipment() async {
|
|
if (!hasSelection) return;
|
|
|
|
final pageContext = context;
|
|
showDialog(
|
|
context: pageContext,
|
|
builder: (dialogContext) => AlertDialog(
|
|
title: const Text(EquipmentDeleteUtils.deleteDialogTitle),
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildBulkDeleteConfirmationMessage(
|
|
selectedCount,
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(dialogContext),
|
|
child: const Text(EquipmentDeleteUtils.deleteDialogCancelLabel),
|
|
),
|
|
TextButton(
|
|
onPressed: () async {
|
|
Navigator.pop(dialogContext);
|
|
final scaffoldMessenger = ScaffoldMessenger.of(pageContext);
|
|
final provider = pageContext.read<EquipmentProvider>();
|
|
|
|
try {
|
|
final equipmentById = {
|
|
for (final equipment
|
|
in provider.equipment)
|
|
equipment.id: equipment,
|
|
};
|
|
|
|
var deletedCount = 0;
|
|
for (final id in selectedIds) {
|
|
final label = EquipmentDeleteUtils.resolveEquipmentLabel(
|
|
id: id,
|
|
name: equipmentById[id]?.name,
|
|
);
|
|
final deleted = await EquipmentDeleteUtils
|
|
.deleteWithFutureAssignmentCheck(
|
|
context: pageContext,
|
|
equipmentLabel: label,
|
|
deleteEquipment: ({bool forceDelete = false}) {
|
|
return provider.deleteEquipment(
|
|
id,
|
|
forceDelete: forceDelete,
|
|
);
|
|
},
|
|
);
|
|
if (deleted) {
|
|
deletedCount++;
|
|
}
|
|
}
|
|
disableSelectionMode();
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildBulkDeleteSuccessMessage(
|
|
deletedCount,
|
|
),
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
} catch (e) {
|
|
scaffoldMessenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
EquipmentDeleteUtils.buildDeleteErrorMessage(e),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
child: const Text(EquipmentDeleteUtils.deleteDialogConfirmLabel),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _generateQRCodesForSelected() async {
|
|
if (!hasSelection) return;
|
|
|
|
// Afficher un indicateur de chargement
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const Center(
|
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
|
),
|
|
);
|
|
|
|
try {
|
|
// Récupérer les équipements sélectionnés
|
|
final provider = context.read<EquipmentProvider>();
|
|
final List<EquipmentModel> selectedEquipment = [];
|
|
|
|
// On doit récupérer les équipements depuis le stream
|
|
await for (final equipmentList in provider.equipmentStream.take(1)) {
|
|
for (final equipment in equipmentList) {
|
|
if (isItemSelected(equipment.id)) {
|
|
selectedEquipment.add(equipment);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
|
|
// Fermer l'indicateur de chargement
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
if (selectedEquipment.isEmpty) return;
|
|
|
|
if (selectedEquipment.length == 1) {
|
|
// Un seul équipement : afficher le dialogue simple
|
|
if (mounted) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) =>
|
|
QRCodeDialog.forEquipment(selectedEquipment.first),
|
|
);
|
|
}
|
|
} else {
|
|
// Plusieurs équipements : afficher le sélecteur de format
|
|
if (mounted) {
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => QRCodeFormatSelectorDialog<EquipmentModel>(
|
|
itemList: selectedEquipment,
|
|
getId: (eq) => eq.id,
|
|
getTitle: (eq) => '${eq.brand ?? ''} ${eq.model ?? ''}'.trim(),
|
|
dialogTitle: 'Générer ${selectedEquipment.length} QR Code(s)',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Fermer l'indicateur si une erreur survient
|
|
if (mounted) {
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
DebugLog.error('[EquipmentManagementPage] Error generating QR codes', e);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur lors de la génération : ${e.toString()}'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _showRestockDialog(EquipmentModel equipment) {
|
|
final TextEditingController quantityController = TextEditingController();
|
|
bool addToTotal = false;
|
|
|
|
showDialog(
|
|
context: context,
|
|
builder: (context) => StatefulBuilder(
|
|
builder: (context, setState) {
|
|
return AlertDialog(
|
|
title: Row(
|
|
children: [
|
|
const Icon(Icons.add_shopping_cart, color: AppColors.rouge),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text('Restock - ${equipment.name}'),
|
|
),
|
|
],
|
|
),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[100],
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'Quantités actuelles',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
color: Colors.grey[700],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Disponible:'),
|
|
Text(
|
|
'${equipment.availableQuantity ?? 0}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
const Text('Total:'),
|
|
Text(
|
|
'${equipment.totalQuantity ?? 0}',
|
|
style: const TextStyle(fontWeight: FontWeight.bold),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
TextField(
|
|
controller: quantityController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Quantité à ajouter/retirer',
|
|
border: OutlineInputBorder(),
|
|
prefixIcon: Icon(Icons.inventory),
|
|
hintText: 'Ex: 10 ou -5',
|
|
helperText:
|
|
'Nombre positif pour ajouter, négatif pour retirer',
|
|
),
|
|
keyboardType:
|
|
const TextInputType.numberWithOptions(signed: true),
|
|
autofocus: true,
|
|
),
|
|
const SizedBox(height: 16),
|
|
CheckboxListTile(
|
|
title: const Text('Ajouter à la quantité totale'),
|
|
subtitle:
|
|
const Text('Mettre à jour aussi la quantité totale'),
|
|
value: addToTotal,
|
|
contentPadding: EdgeInsets.zero,
|
|
onChanged: (bool? value) {
|
|
setState(() {
|
|
addToTotal = value ?? false;
|
|
});
|
|
},
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
child: const Text('Annuler'),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () async {
|
|
final quantityText = quantityController.text.trim();
|
|
if (quantityText.isEmpty) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('Veuillez entrer une quantité')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final quantity = int.tryParse(quantityText);
|
|
if (quantity == null) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Quantité invalide')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
Navigator.pop(context);
|
|
|
|
try {
|
|
final currentAvailable = equipment.availableQuantity ?? 0;
|
|
final currentTotal = equipment.totalQuantity ?? 0;
|
|
|
|
final newAvailable = currentAvailable + quantity;
|
|
final newTotal =
|
|
addToTotal ? currentTotal + quantity : currentTotal;
|
|
|
|
if (newAvailable < 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'La quantité disponible ne peut pas être négative')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (newTotal < 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text(
|
|
'La quantité totale ne peut pas être négative')),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final updatedData = {
|
|
'availableQuantity': newAvailable,
|
|
'totalQuantity': newTotal,
|
|
'updatedAt': DateTime.now().toIso8601String(),
|
|
};
|
|
|
|
final updatedEquipment = equipment.copyWith(
|
|
availableQuantity: newAvailable,
|
|
totalQuantity: newTotal,
|
|
updatedAt: DateTime.now(),
|
|
);
|
|
|
|
await context
|
|
.read<EquipmentProvider>()
|
|
.updateEquipment(updatedEquipment);
|
|
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
quantity > 0
|
|
? 'Ajout de $quantity unité(s) effectué'
|
|
: 'Retrait de ${quantity.abs()} unité(s) effectué',
|
|
),
|
|
backgroundColor: Colors.green,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('Erreur: $e')),
|
|
);
|
|
}
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: AppColors.rouge,
|
|
),
|
|
child: const Text('Valider',
|
|
style: TextStyle(color: Colors.white)),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
void _viewEquipmentDetails(EquipmentModel equipment) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Scanner un QR Code et ouvrir la vue de détail correspondante
|
|
Future<void> _scanQRCode() async {
|
|
try {
|
|
// Ouvrir le scanner
|
|
final scannedCode = await showDialog<String>(
|
|
context: context,
|
|
builder: (context) => const QRCodeScannerDialog(),
|
|
);
|
|
|
|
if (scannedCode == null || scannedCode.isEmpty) {
|
|
return; // L'utilisateur a annulé
|
|
}
|
|
|
|
if (!mounted) return;
|
|
|
|
// Afficher un indicateur de chargement
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => const Center(
|
|
child: CircularProgressIndicator(color: AppColors.rouge),
|
|
),
|
|
);
|
|
|
|
// Rechercher d'abord dans les équipements
|
|
final equipmentProvider = context.read<EquipmentProvider>();
|
|
await equipmentProvider.ensureLoaded();
|
|
|
|
final equipment = equipmentProvider.allEquipment.firstWhere(
|
|
(eq) => eq.id == scannedCode,
|
|
orElse: () => EquipmentModel(
|
|
id: '',
|
|
name: '',
|
|
category: EquipmentCategory.other,
|
|
status: EquipmentStatus.available,
|
|
maintenanceIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (mounted) {
|
|
Navigator.of(context).pop(); // Fermer l'indicateur
|
|
}
|
|
|
|
if (equipment.id.isNotEmpty) {
|
|
// Équipement trouvé
|
|
if (mounted) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => EquipmentDetailPage(equipment: equipment),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Si pas trouvé dans les équipements, chercher dans les conteneurs
|
|
final containerProvider = context.read<ContainerProvider>();
|
|
if (containerProvider.containers.isEmpty) {
|
|
await containerProvider.loadContainers();
|
|
}
|
|
|
|
final container = containerProvider.containers.firstWhere(
|
|
(c) => c.id == scannedCode,
|
|
orElse: () => ContainerModel(
|
|
id: '',
|
|
name: '',
|
|
type: ContainerType.flightCase,
|
|
status: EquipmentStatus.available,
|
|
equipmentIds: [],
|
|
createdAt: DateTime.now(),
|
|
updatedAt: DateTime.now(),
|
|
),
|
|
);
|
|
|
|
if (container.id.isNotEmpty) {
|
|
// Conteneur trouvé
|
|
if (mounted) {
|
|
Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (context) => ContainerDetailPage(container: container),
|
|
),
|
|
);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Rien trouvé
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
'Aucun équipement ou conteneur trouvé avec l\'ID : $scannedCode'),
|
|
backgroundColor: Colors.orange,
|
|
),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
DebugLog.error('[EquipmentManagementPage] Error scanning QR code', e);
|
|
if (mounted) {
|
|
// Fermer l'indicateur si ouvert
|
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text('Erreur lors du scan : ${e.toString()}'),
|
|
backgroundColor: Colors.red,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|