refactor: Passage à la pagination côté serveur pour les équipements et containers
Cette mise à jour refactorise en profondeur le chargement des données pour les équipements et les containers, en remplaçant la récupération complète de la collection par un système de pagination côté serveur. Ce changement améliore considérablement les performances, réduit la consommation de mémoire et accélère le temps de chargement initial, en particulier pour les larges inventaires.
**Changements Backend (Cloud Functions) :**
- **Nouveaux Endpoints Paginés :**
- `getEquipmentsPaginated` et `getContainersPaginated` ont été créés pour remplacer les anciens `getEquipments` et `getContainers`.
- Ces nouvelles fonctions supportent le filtrage (catégorie, statut, type), la recherche textuelle et le tri directement côté serveur, limitant la quantité de données transférées.
- La pagination est gérée via les paramètres `limit` et `startAfter`, assurant un chargement par lots efficace.
- **Optimisation de `getContainersPaginated` :**
- Peuple désormais les containers avec leurs équipements enfants via une requête `in` optimisée, réduisant le nombre de lectures Firestore.
- **Suppression des Anciens Endpoints :** Les fonctions `getEquipments` et `getContainers`, qui chargeaient l'intégralité des collections, ont été supprimées.
- **Nouveau Script de Migration :** Ajout d'un script (`migrate_equipment_ids.js`) pour s'assurer que chaque équipement dans Firestore possède un champ `id` correspondant à son ID de document, ce qui est crucial pour le tri et la pagination.
**Changements Frontend (Flutter) :**
- **`EquipmentProvider` et `ContainerProvider` :**
- La logique de chargement a été entièrement réécrite pour utiliser les nouveaux endpoints paginés.
- Introduction d'un mode `usePagination` pour basculer entre le chargement paginé (pour les pages de gestion) et le chargement complet (pour les dialogues de sélection).
- Implémentation de `loadFirstPage` et `loadNextPage` pour gérer le scroll infini.
- Ajout d'un "debouncing" sur la recherche pour éviter les appels API excessifs lors de la saisie.
- **Pages de Gestion (`EquipmentManagementPage`, `ContainerManagementPage`) :**
- Utilisent désormais un `ScrollController` pour déclencher `loadNextPage` et implémenter un scroll infini.
- Le chargement initial et les rechargements (après filtre) sont beaucoup plus rapides.
- Refonte de l'UI avec un nouveau widget `SearchActionsBar` pour uniformiser la barre de recherche et les actions.
- **Dialogue de Sélection d'Équipement (`EquipmentSelectionDialog`) :**
- Passe également à un système de lazy loading basé sur des `ChoiceChip` pour afficher soit les équipements, soit les containers.
- Charge les pages de manière asynchrone au fur et à mesure du scroll, améliorant drastiquement la réactivité du dialogue.
- La logique de chargement des données a été fiabilisée pour attendre la disponibilité des données avant l'affichage.
- **Optimisations diverses :**
- Les sections qui listent les événements associés à un équipement (`EquipmentCurrentEventsSection`, etc.) chargent désormais uniquement les containers pertinents via `getContainersByIds` au lieu de toute la collection.
- Le calcul du statut d'un équipement (`EquipmentStatusBadge`) est maintenant synchrone, simplifiant le code et évitant des `FutureBuilder`.
**Correction mineure :**
- **Nom de l'application :** Le nom de l'application a été mis à jour de "EM2 ERP" à "EM2 Hub" dans `main.dart` et dans les exports ICS.
This commit is contained in:
@@ -6,7 +6,6 @@ import 'package:em2rp/views/widgets/nav/main_drawer.dart';
|
||||
import 'package:em2rp/views/widgets/nav/custom_app_bar.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/local_user_provider.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/views/equipment_detail_page.dart';
|
||||
@@ -14,10 +13,11 @@ 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/mixins/selection_mode_mixin.dart';
|
||||
import 'package:em2rp/views/widgets/management/management_search_bar.dart';
|
||||
import 'package:em2rp/views/widgets/management/management_card.dart';
|
||||
import 'package:em2rp/views/widgets/management/management_list.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/views/widgets/common/search_actions_bar.dart';
|
||||
import 'package:em2rp/views/widgets/notification_badge.dart';
|
||||
|
||||
class ContainerManagementPage extends StatefulWidget {
|
||||
const ContainerManagementPage({super.key});
|
||||
@@ -30,13 +30,61 @@ class ContainerManagementPage extends StatefulWidget {
|
||||
class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
with SelectionModeMixin<ContainerManagementPage> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
ContainerType? _selectedType;
|
||||
EquipmentStatus? _selectedStatus;
|
||||
List<ContainerModel>? _cachedContainers; // Cache pour éviter le rebuild
|
||||
bool _isLoadingMore = false; // Flag pour éviter les appels multiples
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Activer le mode pagination
|
||||
final provider = context.read<ContainerProvider>();
|
||||
provider.enablePagination();
|
||||
|
||||
// Ajouter le listener de scroll
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
// Charger la première page
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
provider.loadFirstPage();
|
||||
});
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Éviter les appels multiples
|
||||
if (_isLoadingMore) return;
|
||||
|
||||
final provider = context.read<ContainerProvider>();
|
||||
|
||||
// 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) {
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
provider.loadNextPage().then((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}).catchError((error) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScroll);
|
||||
_scrollController.dispose();
|
||||
_searchController.dispose();
|
||||
context.read<ContainerProvider>().disablePagination();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -73,6 +121,7 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
const NotificationBadge(),
|
||||
if (hasSelection) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||
@@ -87,44 +136,14 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
],
|
||||
],
|
||||
)
|
||||
: AppBar(
|
||||
title: const Text('Gestion des Containers'),
|
||||
backgroundColor: AppColors.rouge,
|
||||
: CustomAppBar(
|
||||
title: 'Gestion des Containers',
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
tooltip: 'Retour à la gestion des équipements',
|
||||
onPressed: () => Navigator.pushReplacementNamed(context, '/equipment_management'),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.logout, color: Colors.white),
|
||||
onPressed: () async {
|
||||
final shouldLogout = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Déconnexion'),
|
||||
content: const Text('Voulez-vous vraiment vous déconnecter ?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('Déconnexion'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (shouldLogout == true && context.mounted) {
|
||||
await context.read<LocalUserProvider>().signOut();
|
||||
if (context.mounted) {
|
||||
Navigator.pushReplacementNamed(context, '/login');
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
showLogoutButton: true,
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/container_management'),
|
||||
floatingActionButton: !isSelectionMode
|
||||
@@ -174,21 +193,36 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
}
|
||||
|
||||
Widget _buildSearchBar() {
|
||||
return ManagementSearchBar(
|
||||
return SearchActionsBar(
|
||||
controller: _searchController,
|
||||
hintText: 'Rechercher un container...',
|
||||
onChanged: (value) {
|
||||
context.read<ContainerProvider>().setSearchQuery(value);
|
||||
},
|
||||
onSelectionModeToggle: isSelectionMode ? null : toggleSelectionMode,
|
||||
showSelectionModeButton: !isSelectionMode,
|
||||
additionalActions: [
|
||||
const SizedBox(width: 12),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code_scanner, color: AppColors.rouge),
|
||||
tooltip: 'Scanner un QR Code',
|
||||
onClear: () {
|
||||
_searchController.clear();
|
||||
context.read<ContainerProvider>().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,
|
||||
),
|
||||
),
|
||||
if (!isSelectionMode)
|
||||
IconButton.filled(
|
||||
onPressed: toggleSelectionMode,
|
||||
icon: const Icon(Icons.checklist),
|
||||
tooltip: 'Mode sélection',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
foregroundColor: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -274,30 +308,12 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
...ContainerType.values.map((type) {
|
||||
return _buildFilterOption(type, type.label);
|
||||
}),
|
||||
|
||||
const Divider(height: 32),
|
||||
|
||||
// Filtre par statut
|
||||
Text(
|
||||
'Statut',
|
||||
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppColors.noir,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatusFilter(null, 'Tous les statuts'),
|
||||
_buildStatusFilter(EquipmentStatus.available, 'Disponible'),
|
||||
_buildStatusFilter(EquipmentStatus.inUse, 'En prestation'),
|
||||
_buildStatusFilter(EquipmentStatus.maintenance, 'En maintenance'),
|
||||
_buildStatusFilter(EquipmentStatus.outOfService, 'Hors service'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFilterOption(ContainerType? type, String label) {
|
||||
final isSelected = _selectedType == type;
|
||||
return RadioListTile<ContainerType?>(
|
||||
title: Text(label),
|
||||
value: type,
|
||||
@@ -314,36 +330,62 @@ class _ContainerManagementPageState extends State<ContainerManagementPage>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusFilter(EquipmentStatus? status, String label) {
|
||||
final isSelected = _selectedStatus == status;
|
||||
return RadioListTile<EquipmentStatus?>(
|
||||
title: Text(label),
|
||||
value: status,
|
||||
groupValue: _selectedStatus,
|
||||
activeColor: AppColors.rouge,
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedStatus = value;
|
||||
context.read<ContainerProvider>().setSelectedStatus(_selectedStatus);
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContainerList() {
|
||||
return Consumer<ContainerProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return ManagementList<ContainerModel>(
|
||||
stream: provider.containersStream,
|
||||
cachedItems: _cachedContainers,
|
||||
emptyMessage: 'Aucun container trouvé',
|
||||
emptyIcon: Icons.inventory_2_outlined,
|
||||
onDataReceived: (items) {
|
||||
_cachedContainers = items;
|
||||
// Afficher l'indicateur de chargement initial
|
||||
if (provider.isLoading && provider.containers.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final containers = provider.containers;
|
||||
|
||||
// Afficher le message vide
|
||||
if (containers.isEmpty && !provider.isLoading) {
|
||||
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 container trouvé',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Calculer le nombre total d'items
|
||||
final itemCount = containers.length + (provider.hasMore ? 1 : 0);
|
||||
|
||||
return ListView.builder(
|
||||
controller: _scrollController,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Dernier élément = indicateur de chargement
|
||||
if (index == containers.length) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: provider.isLoadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildContainerCard(containers[index]);
|
||||
},
|
||||
itemBuilder: (container) => _buildContainerCard(container),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -18,6 +18,8 @@ import 'package:em2rp/views/widgets/equipment/equipment_status_badge.dart';
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:em2rp/mixins/selection_mode_mixin.dart';
|
||||
import 'package:em2rp/views/widgets/management/management_list.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});
|
||||
@@ -31,23 +33,66 @@ class EquipmentManagementPage extends StatefulWidget {
|
||||
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');
|
||||
// Charger les équipements au démarrage
|
||||
|
||||
// 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 equipments...');
|
||||
context.read<EquipmentProvider>().loadEquipments();
|
||||
DebugLog.info('[EquipmentManagementPage] Loading first page...');
|
||||
provider.loadFirstPage();
|
||||
});
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
// Éviter les appels multiples
|
||||
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) {
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
provider.loadNextPage().then((_) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}).catchError((error) {
|
||||
if (mounted) {
|
||||
setState(() => _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();
|
||||
}
|
||||
|
||||
@@ -84,6 +129,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
style: const TextStyle(color: Colors.white),
|
||||
),
|
||||
actions: [
|
||||
const NotificationBadge(),
|
||||
if (hasSelection) ...[
|
||||
IconButton(
|
||||
icon: const Icon(Icons.qr_code, color: Colors.white),
|
||||
@@ -100,13 +146,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
)
|
||||
: CustomAppBar(
|
||||
title: 'Gestion du matériel',
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.checklist),
|
||||
tooltip: 'Mode sélection',
|
||||
onPressed: toggleSelectionMode,
|
||||
),
|
||||
],
|
||||
),
|
||||
drawer: const MainDrawer(currentPage: '/equipment_management'),
|
||||
body: isMobile ? _buildMobileLayout() : _buildDesktopLayout(),
|
||||
@@ -130,61 +169,39 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
Widget _buildMobileLayout() {
|
||||
return Column(
|
||||
children: [
|
||||
// Barre de recherche et bouton boîtes
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par nom, modèle ou ID...',
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
context.read<EquipmentProvider>().setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
onChanged: (value) {
|
||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||
},
|
||||
),
|
||||
// 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,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bouton Scanner QR
|
||||
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,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Bouton Gérer les boîtes
|
||||
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(
|
||||
@@ -249,49 +266,7 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// Bouton Gérer les boîtes
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.pushNamed(context, '/container_management');
|
||||
},
|
||||
icon: const Icon(Icons.inventory_2, color: Colors.white),
|
||||
label: const Text(
|
||||
'Gérer les boîtes',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.rouge,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bouton Scanner QR
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16.0, 0, 16.0, 16.0),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _scanQRCode,
|
||||
icon: const Icon(Icons.qr_code_scanner, color: Colors.white),
|
||||
label: const Text(
|
||||
'Scanner QR Code',
|
||||
style: TextStyle(color: Colors.white),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.grey[700],
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 16,
|
||||
),
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 16),
|
||||
// En-tête filtres
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
@@ -312,37 +287,6 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
],
|
||||
),
|
||||
),
|
||||
// Barre de recherche
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher...',
|
||||
prefixIcon: const Icon(Icons.search, size: 20),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear, size: 20),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
context
|
||||
.read<EquipmentProvider>()
|
||||
.setSearchQuery('');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onChanged: (value) {
|
||||
context.read<EquipmentProvider>().setSearchQuery(value);
|
||||
},
|
||||
),
|
||||
),
|
||||
// Filtres par catégorie
|
||||
Padding(
|
||||
padding:
|
||||
@@ -396,7 +340,56 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
),
|
||||
),
|
||||
// Contenu principal
|
||||
Expanded(child: _buildEquipmentList()),
|
||||
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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -469,8 +462,9 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
builder: (context, provider, child) {
|
||||
DebugLog.info('[EquipmentManagementPage] Building list - isLoading: ${provider.isLoading}, equipment count: ${provider.equipment.length}');
|
||||
|
||||
if (provider.isLoading && _cachedEquipment == null) {
|
||||
DebugLog.info('[EquipmentManagementPage] Showing loading indicator');
|
||||
// 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());
|
||||
}
|
||||
|
||||
@@ -501,9 +495,26 @@ class _EquipmentManagementPageState extends State<EquipmentManagementPage>
|
||||
}
|
||||
|
||||
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(
|
||||
itemCount: equipments.length,
|
||||
controller: _scrollController,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
// Dernier élément = indicateur de chargement
|
||||
if (index == equipments.length) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: provider.isLoadingMore
|
||||
? const CircularProgressIndicator()
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _buildEquipmentCard(equipments[index]);
|
||||
},
|
||||
);
|
||||
|
||||
61
em2rp/lib/views/widgets/common/search_actions_bar.dart
Normal file
61
em2rp/lib/views/widgets/common/search_actions_bar.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SearchActionsBar extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String hintText;
|
||||
final ValueChanged<String> onChanged;
|
||||
final VoidCallback onClear;
|
||||
final List<Widget> actions;
|
||||
|
||||
const SearchActionsBar({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.hintText,
|
||||
required this.onChanged,
|
||||
required this.onClear,
|
||||
this.actions = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: onClear,
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
),
|
||||
onChanged: onChanged,
|
||||
),
|
||||
),
|
||||
if (actions.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (int i = 0; i < actions.length; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
actions[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,19 +49,28 @@ class _EquipmentAssociatedEventsSectionState
|
||||
|
||||
final events = <EventModel>[];
|
||||
|
||||
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API
|
||||
final containersData = await _dataService.getContainers();
|
||||
// Collecter tous les IDs de containers utilisés dans les événements
|
||||
final allContainerIds = <String>{};
|
||||
for (var eventData in eventsData) {
|
||||
final assignedContainers = eventData['assignedContainers'] as List<dynamic>? ?? [];
|
||||
allContainerIds.addAll(assignedContainers.map((id) => id.toString()));
|
||||
}
|
||||
|
||||
// Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger)
|
||||
final containersWithEquipment = <String>[];
|
||||
for (var containerData in containersData) {
|
||||
try {
|
||||
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
|
||||
if (allContainerIds.isNotEmpty) {
|
||||
final containersData = await _dataService.getContainersByIds(allContainerIds.toList());
|
||||
|
||||
if (equipmentIds.contains(widget.equipment.id)) {
|
||||
containersWithEquipment.add(containerData['id'] as String);
|
||||
for (var containerData in containersData) {
|
||||
try {
|
||||
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
|
||||
|
||||
if (equipmentIds.contains(widget.equipment.id)) {
|
||||
containersWithEquipment.add(containerData['id'] as String);
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentAssociatedEventsSection] Error parsing container ${containerData['id']}', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -43,19 +43,28 @@ class _EquipmentCurrentEventsSectionState
|
||||
|
||||
final events = <EventModel>[];
|
||||
|
||||
// Récupérer toutes les boîtes pour vérifier leur contenu via l'API
|
||||
final containersData = await _dataService.getContainers();
|
||||
// Collecter tous les IDs de containers utilisés dans les événements
|
||||
final allContainerIds = <String>{};
|
||||
for (var eventData in eventsData) {
|
||||
final assignedContainers = eventData['assignedContainers'] as List<dynamic>? ?? [];
|
||||
allContainerIds.addAll(assignedContainers.map((id) => id.toString()));
|
||||
}
|
||||
|
||||
// Charger SEULEMENT les containers utilisés (au lieu de TOUS les charger)
|
||||
final containersWithEquipment = <String>[];
|
||||
for (var containerData in containersData) {
|
||||
try {
|
||||
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
|
||||
if (allContainerIds.isNotEmpty) {
|
||||
final containersData = await _dataService.getContainersByIds(allContainerIds.toList());
|
||||
|
||||
if (equipmentIds.contains(widget.equipment.id)) {
|
||||
containersWithEquipment.add(containerData['id'] as String);
|
||||
for (var containerData in containersData) {
|
||||
try {
|
||||
final equipmentIds = List<String>.from(containerData['equipmentIds'] ?? []);
|
||||
|
||||
if (equipmentIds.contains(widget.equipment.id)) {
|
||||
containersWithEquipment.add(containerData['id'] as String);
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentCurrentEventsSection] Error parsing container ${containerData['id']}', e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,35 +16,25 @@ class EquipmentStatusBadge extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = Provider.of<EquipmentProvider>(context, listen: false);
|
||||
// Calculer le statut réel (synchrone maintenant)
|
||||
final status = provider.calculateRealStatus(equipment);
|
||||
|
||||
// Logs désactivés en production
|
||||
|
||||
return FutureBuilder<EquipmentStatus>(
|
||||
// On calcule le statut réel de manière asynchrone
|
||||
future: provider.calculateRealStatus(equipment),
|
||||
// En attendant, on affiche le statut stocké
|
||||
initialData: equipment.status,
|
||||
builder: (context, snapshot) {
|
||||
// Utiliser le statut calculé s'il est disponible, sinon le statut stocké
|
||||
final status = snapshot.data ?? equipment.status;
|
||||
// Logs désactivés en production
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: status.color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: status.color),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: status.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: status.color.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: status.color),
|
||||
),
|
||||
child: Text(
|
||||
status.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: status.color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,435 +0,0 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/utils/colors.dart';
|
||||
|
||||
/// Widget pour sélectionner les boîtes parentes d'un équipement
|
||||
class ParentBoxesSelector extends StatefulWidget {
|
||||
final List<ContainerModel> availableBoxes;
|
||||
final List<String> selectedBoxIds;
|
||||
final Function(List<String>) onSelectionChanged;
|
||||
|
||||
const ParentBoxesSelector({
|
||||
super.key,
|
||||
required this.availableBoxes,
|
||||
required this.selectedBoxIds,
|
||||
required this.onSelectionChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<ParentBoxesSelector> createState() => _ParentBoxesSelectorState();
|
||||
}
|
||||
|
||||
class _ParentBoxesSelectorState extends State<ParentBoxesSelector> {
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
String _searchQuery = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ParentBoxesSelector oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<ContainerModel> get _filteredBoxes {
|
||||
if (_searchQuery.isEmpty) {
|
||||
return widget.availableBoxes;
|
||||
}
|
||||
|
||||
final query = _searchQuery.toLowerCase();
|
||||
return widget.availableBoxes.where((box) {
|
||||
return box.name.toLowerCase().contains(query) ||
|
||||
box.id.toLowerCase().contains(query) ||
|
||||
box.type.label.toLowerCase().contains(query);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
void _toggleSelection(String boxId) {
|
||||
final newSelection = List<String>.from(widget.selectedBoxIds);
|
||||
if (newSelection.contains(boxId)) {
|
||||
newSelection.remove(boxId);
|
||||
} else {
|
||||
newSelection.add(boxId);
|
||||
}
|
||||
widget.onSelectionChanged(newSelection);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.availableBoxes.isEmpty && widget.selectedBoxIds.isEmpty) {
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Aucune boîte disponible',
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final filteredBoxes = _filteredBoxes;
|
||||
final selectedCount = widget.selectedBoxIds.length;
|
||||
|
||||
// Vérifier s'il y a des boîtes sélectionnées qui ne sont pas dans la liste
|
||||
final missingBoxIds = widget.selectedBoxIds
|
||||
.where((id) => !widget.availableBoxes.any((box) => box.id == id))
|
||||
.toList();
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// En-tête avec titre et compteur
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.inventory_2, color: AppColors.rouge, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'Boîtes parentes',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (selectedCount > 0)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.rouge.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.rouge.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'$selectedCount sélectionné${selectedCount > 1 ? 's' : ''}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.rouge,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
|
||||
// Message d'avertissement si des boîtes sélectionnées sont manquantes
|
||||
if (missingBoxIds.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.shade50,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.orange.shade300),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.warning_amber, color: Colors.orange.shade700),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Boîtes introuvables',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.orange.shade900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Les boîtes suivantes sont sélectionnées mais n\'existent plus : ${missingBoxIds.join(", ")}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.orange.shade800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () {
|
||||
// Retirer les boîtes manquantes de la sélection
|
||||
final newSelection = widget.selectedBoxIds
|
||||
.where((id) => !missingBoxIds.contains(id))
|
||||
.toList();
|
||||
widget.onSelectionChanged(newSelection);
|
||||
},
|
||||
tooltip: 'Retirer ces boîtes',
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Barre de recherche
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: TextField(
|
||||
controller: _searchController,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_searchQuery = value;
|
||||
});
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher par nom, ID ou type...',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.rouge),
|
||||
suffixIcon: _searchQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_searchController.clear();
|
||||
_searchQuery = '';
|
||||
});
|
||||
},
|
||||
)
|
||||
: null,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.rouge, width: 2),
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade50,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Message si aucun résultat
|
||||
if (filteredBoxes.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Center(
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Aucune boîte trouvée',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Essayez une autre recherche',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
// Liste des boîtes
|
||||
ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: filteredBoxes.length,
|
||||
separatorBuilder: (context, index) => const SizedBox(height: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final box = filteredBoxes[index];
|
||||
final isSelected = widget.selectedBoxIds.contains(box.id);
|
||||
if (index == 0) {
|
||||
DebugLog.info('[ParentBoxesSelector] Building item $index');
|
||||
DebugLog.info('[ParentBoxesSelector] Box ID: ${box.id}');
|
||||
DebugLog.info('[ParentBoxesSelector] Selected IDs: ${widget.selectedBoxIds}');
|
||||
DebugLog.info('[ParentBoxesSelector] Is selected: $isSelected');
|
||||
}
|
||||
return _buildBoxCard(box, isSelected);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBoxCard(ContainerModel box, bool isSelected) {
|
||||
return Card(
|
||||
elevation: isSelected ? 3 : 1,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: isSelected ? AppColors.rouge : Colors.grey.shade300,
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => _toggleSelection(box.id),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Checkbox
|
||||
Checkbox(
|
||||
value: isSelected,
|
||||
onChanged: (value) => _toggleSelection(box.id),
|
||||
activeColor: AppColors.rouge,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Icône du type de container
|
||||
CircleAvatar(
|
||||
backgroundColor: isSelected
|
||||
? AppColors.rouge.withValues(alpha: 0.15)
|
||||
: Colors.grey.shade200,
|
||||
radius: 24,
|
||||
child: box.type.getIconForAvatar(
|
||||
size: 24,
|
||||
color: isSelected ? AppColors.rouge : Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Informations de la boîte
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
box.name,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
color: isSelected ? AppColors.rouge : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
box.type.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// Badges
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
icon: Icons.inventory,
|
||||
label: '${box.itemCount} équip.',
|
||||
color: Colors.blue,
|
||||
),
|
||||
if (box.weight != null)
|
||||
_buildInfoChip(
|
||||
icon: Icons.scale,
|
||||
label: '${box.weight!.toStringAsFixed(1)} kg',
|
||||
color: Colors.orange,
|
||||
),
|
||||
_buildInfoChip(
|
||||
icon: Icons.tag,
|
||||
label: box.id,
|
||||
color: Colors.grey,
|
||||
isCompact: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Indicateur de sélection
|
||||
if (isSelected)
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColors.rouge,
|
||||
size: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip({
|
||||
required IconData icon,
|
||||
required String label,
|
||||
required Color color,
|
||||
bool isCompact = false,
|
||||
}) {
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 6 : 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: color.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
icon,
|
||||
size: isCompact ? 10 : 12,
|
||||
color: color.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: isCompact ? 9 : 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color.withValues(alpha: 0.9),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'package:em2rp/utils/debug_log.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:em2rp/models/equipment_model.dart';
|
||||
import 'package:em2rp/models/container_model.dart';
|
||||
import 'package:em2rp/models/event_model.dart';
|
||||
import 'package:em2rp/providers/equipment_provider.dart';
|
||||
import 'package:em2rp/providers/container_provider.dart';
|
||||
import 'package:em2rp/services/event_availability_service.dart';
|
||||
import 'package:em2rp/services/data_service.dart';
|
||||
import 'package:em2rp/services/api_service.dart';
|
||||
@@ -109,93 +106,70 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
Map<String, dynamic> _conflictDetails = {}; // Détails des conflits par ID
|
||||
Map<String, dynamic> _equipmentQuantities = {}; // Infos de quantités pour câbles/consommables
|
||||
|
||||
bool _isLoadingQuantities = false;
|
||||
bool _isLoadingConflicts = false;
|
||||
String _searchQuery = '';
|
||||
|
||||
// Nouvelles options d'affichage
|
||||
bool _showConflictingItems = false; // Afficher les équipements/boîtes en conflit
|
||||
bool _containersExpanded = true; // Section "Boîtes" dépliée
|
||||
bool _equipmentExpanded = true; // Section "Tous les équipements" dépliée
|
||||
|
||||
// NOUVEAU : Lazy loading et pagination
|
||||
SelectionType _displayType = SelectionType.equipment; // Type affiché (équipements OU containers)
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMoreEquipments = true;
|
||||
bool _hasMoreContainers = true;
|
||||
String? _lastEquipmentId;
|
||||
String? _lastContainerId;
|
||||
List<EquipmentModel> _paginatedEquipments = [];
|
||||
List<ContainerModel> _paginatedContainers = [];
|
||||
|
||||
// Cache pour éviter les rebuilds inutiles
|
||||
List<ContainerModel> _cachedContainers = [];
|
||||
List<EquipmentModel> _cachedEquipment = [];
|
||||
bool _initialDataLoaded = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// Ajouter le listener de scroll pour lazy loading
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
// Charger immédiatement les données de manière asynchrone
|
||||
_initializeData();
|
||||
}
|
||||
|
||||
/// Gestion du scroll pour lazy loading
|
||||
void _onScroll() {
|
||||
if (_isLoadingMore) return;
|
||||
|
||||
if (_scrollController.hasClients &&
|
||||
_scrollController.position.pixels >= _scrollController.position.maxScrollExtent - 300) {
|
||||
// Charger la page suivante selon le type affiché
|
||||
if (_displayType == SelectionType.equipment && _hasMoreEquipments) {
|
||||
_loadNextEquipmentPage();
|
||||
} else if (_displayType == SelectionType.container && _hasMoreContainers) {
|
||||
_loadNextContainerPage();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialise toutes les données nécessaires
|
||||
Future<void> _initializeData() async {
|
||||
try {
|
||||
// 1. S'assurer que les équipements et conteneurs sont chargés
|
||||
await _ensureEquipmentsLoaded();
|
||||
// 1. Charger les conflits (batch optimisé)
|
||||
await _loadEquipmentConflicts();
|
||||
|
||||
// 2. Mettre à jour le cache immédiatement après le chargement
|
||||
if (mounted) {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
setState(() {
|
||||
// Utiliser allEquipment pour avoir TOUS les équipements sans filtres
|
||||
_cachedEquipment = equipmentProvider.allEquipment;
|
||||
_cachedContainers = containerProvider.containers;
|
||||
_initialDataLoaded = true;
|
||||
});
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Cache updated: ${_cachedEquipment.length} equipment(s), ${_cachedContainers.length} container(s)');
|
||||
}
|
||||
|
||||
// 3. Initialiser la sélection avec le matériel déjà assigné
|
||||
// 2. Initialiser la sélection avec le matériel déjà assigné
|
||||
await _initializeAlreadyAssigned();
|
||||
|
||||
// 4. Charger les quantités et conflits en parallèle
|
||||
await Future.wait([
|
||||
_loadAvailableQuantities(),
|
||||
_loadEquipmentConflicts(),
|
||||
]);
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error during initialization', e);
|
||||
}
|
||||
}
|
||||
|
||||
/// S'assure que les équipements sont chargés avant d'utiliser le dialog
|
||||
Future<void> _ensureEquipmentsLoaded() async {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Starting equipment loading...');
|
||||
|
||||
// Forcer le chargement et attendre qu'il soit terminé
|
||||
await equipmentProvider.ensureLoaded();
|
||||
|
||||
// Attendre que le chargement soit vraiment terminé
|
||||
while (equipmentProvider.isLoading) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
}
|
||||
|
||||
// Vérifier qu'on a bien des équipements chargés
|
||||
if (equipmentProvider.allEquipment.isEmpty) {
|
||||
DebugLog.warning('[EquipmentSelectionDialog] No equipment loaded after ensureLoaded!');
|
||||
}
|
||||
|
||||
// Charger aussi les conteneurs si nécessaire
|
||||
if (containerProvider.containers.isEmpty) {
|
||||
await containerProvider.loadContainers();
|
||||
|
||||
// Attendre que le chargement des conteneurs soit terminé
|
||||
while (containerProvider.isLoading) {
|
||||
await Future.delayed(const Duration(milliseconds: 100));
|
||||
// 3. Charger la première page selon le type sélectionné
|
||||
if (_displayType == SelectionType.equipment) {
|
||||
await _loadNextEquipmentPage();
|
||||
} else {
|
||||
await _loadNextContainerPage();
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error initializing data', e);
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Data loaded: ${equipmentProvider.allEquipment.length} equipment(s), ${containerProvider.containers.length} container(s)');
|
||||
}
|
||||
|
||||
/// Initialise la sélection avec le matériel déjà assigné
|
||||
@@ -215,15 +189,15 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
// Ajouter les conteneurs déjà assignés
|
||||
if (widget.alreadyAssignedContainers.isNotEmpty) {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
|
||||
// Pour les conteneurs déjà assignés, on va les chercher via l'API si nécessaire
|
||||
// ou créer des conteneurs temporaires
|
||||
for (var containerId in widget.alreadyAssignedContainers) {
|
||||
final container = containers.firstWhere(
|
||||
// Chercher dans le cache ou créer un conteneur temporaire
|
||||
final container = _cachedContainers.firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
orElse: () => ContainerModel(
|
||||
id: containerId,
|
||||
name: 'Inconnu',
|
||||
name: 'Conteneur $containerId',
|
||||
type: ContainerType.flightCase,
|
||||
status: EquipmentStatus.available,
|
||||
equipmentIds: [],
|
||||
@@ -267,6 +241,152 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
DebugLog.info('[EquipmentSelectionDialog] Initialized with ${_selectedItems.length} already assigned items');
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge la page suivante d'équipements (lazy loading)
|
||||
Future<void> _loadNextEquipmentPage() async {
|
||||
if (_isLoadingMore || !_hasMoreEquipments) return;
|
||||
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
try {
|
||||
final result = await _dataService.getEquipmentsPaginated(
|
||||
limit: 25,
|
||||
startAfter: _lastEquipmentId,
|
||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||
category: _selectedCategory != null ? equipmentCategoryToString(_selectedCategory!) : null,
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final newEquipments = (result['equipments'] as List<dynamic>)
|
||||
.map((data) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
final id = map['id'] as String;
|
||||
return EquipmentModel.fromMap(map, id);
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_paginatedEquipments.addAll(newEquipments);
|
||||
_hasMoreEquipments = result['hasMore'] as bool? ?? false;
|
||||
_lastEquipmentId = result['lastVisible'] as String?;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newEquipments.length} equipments, total: ${_paginatedEquipments.length}, hasMore: $_hasMoreEquipments');
|
||||
|
||||
// Charger les quantités pour les consommables/câbles de cette page
|
||||
await _loadAvailableQuantities(newEquipments);
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error loading equipment page', e);
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Charge la page suivante de containers (lazy loading)
|
||||
Future<void> _loadNextContainerPage() async {
|
||||
if (_isLoadingMore || !_hasMoreContainers) return;
|
||||
|
||||
setState(() => _isLoadingMore = true);
|
||||
|
||||
try {
|
||||
final result = await _dataService.getContainersPaginated(
|
||||
limit: 25,
|
||||
startAfter: _lastContainerId,
|
||||
searchQuery: _searchQuery.isNotEmpty ? _searchQuery : null,
|
||||
category: _selectedCategory?.name, // Filtre par catégorie d'équipements
|
||||
sortBy: 'id',
|
||||
sortOrder: 'asc',
|
||||
);
|
||||
|
||||
final containersData = result['containers'] as List<dynamic>;
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Raw containers data received: ${containersData.length} containers');
|
||||
|
||||
// D'abord, extraire TOUS les équipements
|
||||
final List<EquipmentModel> allEquipmentsToCache = [];
|
||||
for (var data in containersData) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
final containerId = map['id'] as String;
|
||||
|
||||
// Debug: vérifier si le champ 'equipment' existe
|
||||
final hasEquipmentField = map.containsKey('equipment');
|
||||
final equipmentData = map['equipment'];
|
||||
DebugLog.info('[EquipmentSelectionDialog] Container $containerId: hasEquipmentField=$hasEquipmentField, equipmentData type=${equipmentData?.runtimeType}, count=${equipmentData is List ? equipmentData.length : 0}');
|
||||
|
||||
final equipmentList = (map['equipment'] as List<dynamic>?)
|
||||
?.map((eqData) {
|
||||
final eqMap = eqData as Map<String, dynamic>;
|
||||
final eqId = eqMap['id'] as String;
|
||||
DebugLog.info('[EquipmentSelectionDialog] - Equipment found: $eqId');
|
||||
return EquipmentModel.fromMap(eqMap, eqId);
|
||||
})
|
||||
.toList() ?? [];
|
||||
allEquipmentsToCache.addAll(equipmentList);
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Total equipments extracted from containers: ${allEquipmentsToCache.length}');
|
||||
|
||||
// Créer les containers
|
||||
final newContainers = containersData
|
||||
.map((data) {
|
||||
final map = data as Map<String, dynamic>;
|
||||
final id = map['id'] as String;
|
||||
return ContainerModel.fromMap(map, id);
|
||||
})
|
||||
.toList();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
// Ajouter tous les équipements au cache DANS le setState
|
||||
for (var eq in allEquipmentsToCache) {
|
||||
if (!_cachedEquipment.any((e) => e.id == eq.id)) {
|
||||
_cachedEquipment.add(eq);
|
||||
}
|
||||
}
|
||||
|
||||
_paginatedContainers.addAll(newContainers);
|
||||
_hasMoreContainers = result['hasMore'] as bool? ?? false;
|
||||
_lastContainerId = result['lastVisible'] as String?;
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Loaded ${newContainers.length} containers, total: ${_paginatedContainers.length}, hasMore: $_hasMoreContainers');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Cached ${allEquipmentsToCache.length} equipment(s) from containers, total cache: ${_cachedEquipment.length}');
|
||||
|
||||
// Mettre à jour les statuts de conflit pour les nouveaux containers
|
||||
await _updateContainerConflictStatus();
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error loading container page', e);
|
||||
if (mounted) {
|
||||
setState(() => _isLoadingMore = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recharge depuis le début (appelé lors d'un changement de filtre/recherche)
|
||||
Future<void> _reloadData() async {
|
||||
setState(() {
|
||||
_paginatedEquipments.clear();
|
||||
_paginatedContainers.clear();
|
||||
_lastEquipmentId = null;
|
||||
_lastContainerId = null;
|
||||
_hasMoreEquipments = true;
|
||||
_hasMoreContainers = true;
|
||||
});
|
||||
|
||||
if (_displayType == SelectionType.equipment) {
|
||||
await _loadNextEquipmentPage();
|
||||
} else {
|
||||
await _loadNextContainerPage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
@@ -275,34 +395,29 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Charge les quantités disponibles pour tous les consommables/câbles
|
||||
Future<void> _loadAvailableQuantities() async {
|
||||
/// Charge les quantités disponibles pour les consommables/câbles d'une liste d'équipements
|
||||
Future<void> _loadAvailableQuantities(List<EquipmentModel> equipments) async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoadingQuantities = true);
|
||||
|
||||
try {
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
|
||||
// Utiliser directement allEquipment du provider (déjà chargé)
|
||||
final equipment = equipmentProvider.allEquipment;
|
||||
|
||||
final consumables = equipment.where((eq) =>
|
||||
final consumables = equipments.where((eq) =>
|
||||
eq.category == EquipmentCategory.consumable ||
|
||||
eq.category == EquipmentCategory.cable);
|
||||
|
||||
for (var eq in consumables) {
|
||||
final available = await _availabilityService.getAvailableQuantity(
|
||||
equipment: eq,
|
||||
startDate: widget.startDate,
|
||||
endDate: widget.endDate,
|
||||
excludeEventId: widget.excludeEventId,
|
||||
);
|
||||
_availableQuantities[eq.id] = available;
|
||||
// Ne recharger que si on n'a pas déjà la quantité
|
||||
if (!_availableQuantities.containsKey(eq.id)) {
|
||||
final available = await _availabilityService.getAvailableQuantity(
|
||||
equipment: eq,
|
||||
startDate: widget.startDate,
|
||||
endDate: widget.endDate,
|
||||
excludeEventId: widget.excludeEventId,
|
||||
);
|
||||
_availableQuantities[eq.id] = available;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('Error loading quantities', e);
|
||||
} finally {
|
||||
if (mounted) setState(() => _isLoadingQuantities = false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -351,6 +466,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
_conflictingContainerIds = conflictingContainerIds;
|
||||
_conflictDetails = conflictDetails;
|
||||
_equipmentQuantities = equipmentQuantities;
|
||||
|
||||
// Convertir conflictDetails en equipmentConflicts pour l'affichage détaillé
|
||||
_equipmentConflicts.clear();
|
||||
conflictDetails.forEach((itemId, conflicts) {
|
||||
final conflictList = (conflicts as List<dynamic>).map((conflict) {
|
||||
final conflictMap = conflict as Map<String, dynamic>;
|
||||
|
||||
// Créer un EventModel minimal pour le conflit
|
||||
final conflictEvent = EventModel(
|
||||
id: conflictMap['eventId'] as String,
|
||||
name: conflictMap['eventName'] as String,
|
||||
description: '',
|
||||
startDateTime: DateTime.parse(conflictMap['startDate'] as String),
|
||||
endDateTime: DateTime.parse(conflictMap['endDate'] as String),
|
||||
basePrice: 0.0,
|
||||
installationTime: 0,
|
||||
disassemblyTime: 0,
|
||||
eventTypeId: '',
|
||||
customerId: '',
|
||||
address: '',
|
||||
latitude: 0.0,
|
||||
longitude: 0.0,
|
||||
workforce: const [],
|
||||
documents: const [],
|
||||
options: const [],
|
||||
status: EventStatus.confirmed,
|
||||
assignedEquipment: const [],
|
||||
assignedContainers: const [],
|
||||
);
|
||||
|
||||
// Calculer les jours de chevauchement
|
||||
final conflictStart = DateTime.parse(conflictMap['startDate'] as String);
|
||||
final conflictEnd = DateTime.parse(conflictMap['endDate'] as String);
|
||||
final overlapStart = widget.startDate.isAfter(conflictStart) ? widget.startDate : conflictStart;
|
||||
final overlapEnd = widget.endDate.isBefore(conflictEnd) ? widget.endDate : conflictEnd;
|
||||
final overlapDays = overlapEnd.difference(overlapStart).inDays + 1;
|
||||
|
||||
return AvailabilityConflict(
|
||||
equipmentId: itemId,
|
||||
equipmentName: '', // Sera résolu lors de l'affichage
|
||||
conflictingEvent: conflictEvent,
|
||||
overlapDays: overlapDays.clamp(1, 999),
|
||||
);
|
||||
}).toList();
|
||||
_equipmentConflicts[itemId] = conflictList;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,15 +527,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Met à jour le statut de conflit des conteneurs basé sur les IDs en conflit
|
||||
Future<void> _updateContainerConflictStatus() async {
|
||||
if (!mounted) return; // Vérifier si le widget est toujours monté
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final containers = await containerProvider.containersStream.first;
|
||||
|
||||
if (!mounted) return; // Vérifier à nouveau après l'async
|
||||
|
||||
for (var container in containers) {
|
||||
// Utiliser les containers paginés chargés
|
||||
for (var container in _paginatedContainers) {
|
||||
// Vérifier si le conteneur lui-même est en conflit
|
||||
if (_conflictingContainerIds.contains(container.id)) {
|
||||
_containerConflicts[container.id] = ContainerConflictInfo(
|
||||
@@ -406,6 +563,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
}
|
||||
|
||||
DebugLog.info('[EquipmentSelectionDialog] Total containers with conflicts: ${_containerConflicts.length}');
|
||||
|
||||
// Déclencher un rebuild pour afficher les changements visuels
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Error updating container conflicts', e);
|
||||
}
|
||||
@@ -639,26 +801,11 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
}
|
||||
|
||||
/// Recherche les conteneurs recommandés pour un équipement
|
||||
/// NOTE: Désactivé avec le lazy loading - on ne charge pas tous les containers d'un coup
|
||||
Future<void> _findRecommendedContainers(String equipmentId) async {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
// Récupérer les conteneurs depuis le stream
|
||||
final containerStream = containerProvider.containersStream;
|
||||
final containers = await containerStream.first;
|
||||
|
||||
final recommended = containers
|
||||
.where((container) => container.equipmentIds.contains(equipmentId))
|
||||
.toList();
|
||||
|
||||
if (recommended.isNotEmpty) {
|
||||
setState(() {
|
||||
_recommendedContainers[equipmentId] = recommended;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
DebugLog.error('Error finding recommended containers', e);
|
||||
}
|
||||
// Désactivé pour le moment avec le lazy loading
|
||||
// On pourrait implémenter une API dédiée si nécessaire
|
||||
return;
|
||||
}
|
||||
|
||||
/// Obtenir les boîtes parentes d'un équipement de manière synchrone depuis le cache
|
||||
@@ -733,14 +880,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
/// Sélectionner tous les enfants d'un conteneur
|
||||
Future<void> _selectContainerChildren(String containerId) async {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
|
||||
// Utiliser le cache si disponible
|
||||
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
final equipment = _cachedEquipment.isNotEmpty ? _cachedEquipment : equipmentProvider.allEquipment;
|
||||
|
||||
final container = containers.firstWhere(
|
||||
// Chercher le container dans les données paginées ou le cache
|
||||
final container = [..._paginatedContainers, ..._cachedContainers].firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
orElse: () => ContainerModel(
|
||||
id: containerId,
|
||||
@@ -759,7 +900,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
// Sélectionner chaque enfant (sans bloquer, car ils sont "composés")
|
||||
for (var equipmentId in container.equipmentIds) {
|
||||
if (!_selectedItems.containsKey(equipmentId)) {
|
||||
final eq = equipment.firstWhere(
|
||||
// Chercher l'équipement dans les données paginées ou le cache
|
||||
final eq = [..._paginatedEquipments, ..._cachedEquipment].firstWhere(
|
||||
(e) => e.id == equipmentId,
|
||||
orElse: () => EquipmentModel(
|
||||
id: equipmentId,
|
||||
@@ -794,12 +936,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
/// Désélectionner tous les enfants d'un conteneur
|
||||
Future<void> _deselectContainerChildren(String containerId) async {
|
||||
try {
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
|
||||
// Utiliser le cache si disponible
|
||||
final containers = _cachedContainers.isNotEmpty ? _cachedContainers : containerProvider.containers;
|
||||
|
||||
final container = containers.firstWhere(
|
||||
// Chercher le container dans les données paginées ou le cache
|
||||
final container = [..._paginatedContainers, ..._cachedContainers].firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
orElse: () => ContainerModel(
|
||||
id: containerId,
|
||||
@@ -1027,6 +1165,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
onChanged: (value) {
|
||||
setState(() => _searchQuery = value.toLowerCase());
|
||||
// Recharger depuis le début avec le nouveau filtre
|
||||
_reloadData();
|
||||
},
|
||||
),
|
||||
|
||||
@@ -1078,6 +1218,52 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Chip pour switcher entre Équipements et Containers
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Afficher :',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: const Text('Équipements'),
|
||||
selected: _displayType == SelectionType.equipment,
|
||||
onSelected: (selected) {
|
||||
if (selected && _displayType != SelectionType.equipment) {
|
||||
setState(() {
|
||||
_displayType = SelectionType.equipment;
|
||||
});
|
||||
_reloadData();
|
||||
}
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _displayType == SelectionType.equipment ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ChoiceChip(
|
||||
label: const Text('Containers'),
|
||||
selected: _displayType == SelectionType.container,
|
||||
onSelected: (selected) {
|
||||
if (selected && _displayType != SelectionType.container) {
|
||||
setState(() {
|
||||
_displayType = SelectionType.container;
|
||||
});
|
||||
_reloadData();
|
||||
}
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
labelStyle: TextStyle(
|
||||
color: _displayType == SelectionType.container ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -1093,6 +1279,8 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
setState(() {
|
||||
_selectedCategory = selected ? category : null;
|
||||
});
|
||||
// Recharger depuis le début avec le nouveau filtre
|
||||
_reloadData();
|
||||
},
|
||||
selectedColor: AppColors.rouge,
|
||||
checkmarkColor: Colors.white,
|
||||
@@ -1104,7 +1292,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
Widget _buildMainList() {
|
||||
// Afficher un indicateur de chargement si les données sont en cours de chargement
|
||||
if (_isLoadingQuantities || _isLoadingConflicts) {
|
||||
if (_isLoadingConflicts) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@@ -1112,9 +1300,7 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
const CircularProgressIndicator(color: AppColors.rouge),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_isLoadingConflicts
|
||||
? 'Vérification de la disponibilité...'
|
||||
: 'Chargement des quantités disponibles...',
|
||||
'Vérification de la disponibilité...',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
@@ -1128,150 +1314,105 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Vue hiérarchique unique avec cache pour éviter les rebuilds inutiles
|
||||
Widget _buildHierarchicalList() {
|
||||
return Consumer2<ContainerProvider, EquipmentProvider>(
|
||||
builder: (context, containerProvider, equipmentProvider, child) {
|
||||
// Utiliser les données du cache si disponibles, sinon utiliser allEquipment des providers
|
||||
final allContainers = _initialDataLoaded ? _cachedContainers : containerProvider.containers;
|
||||
final allEquipment = _initialDataLoaded ? _cachedEquipment : equipmentProvider.allEquipment;
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _selectionChangeNotifier,
|
||||
builder: (context, _, __) {
|
||||
// Filtrer les données paginées selon le type affiché
|
||||
List<Widget> itemWidgets = [];
|
||||
|
||||
// Utiliser ValueListenableBuilder pour rebuild uniquement sur changement de sélection
|
||||
return ValueListenableBuilder<int>(
|
||||
valueListenable: _selectionChangeNotifier,
|
||||
builder: (context, _, __) {
|
||||
// Filtrage des boîtes
|
||||
final filteredContainers = allContainers.where((container) {
|
||||
// Filtre par conflit (masquer si non cochée et en conflit)
|
||||
if (!_showConflictingItems && _conflictingContainerIds.contains(container.id)) {
|
||||
return false;
|
||||
}
|
||||
if (_displayType == SelectionType.equipment) {
|
||||
// Filtrer côté client pour "Afficher équipements déjà utilisés"
|
||||
final filteredEquipments = _paginatedEquipments.where((eq) {
|
||||
if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
// Filtre par catégorie : afficher uniquement les boîtes contenant au moins 1 équipement de la catégorie
|
||||
if (_selectedCategory != null) {
|
||||
final hasEquipmentOfCategory = container.equipmentIds.any((eqId) {
|
||||
final equipment = allEquipment.firstWhere(
|
||||
(e) => e.id == eqId,
|
||||
orElse: () => EquipmentModel(
|
||||
id: '',
|
||||
name: '',
|
||||
category: EquipmentCategory.other,
|
||||
status: EquipmentStatus.available,
|
||||
maintenanceIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
return equipment.id.isNotEmpty && equipment.category == _selectedCategory;
|
||||
});
|
||||
itemWidgets = filteredEquipments.map((equipment) {
|
||||
return _buildEquipmentCard(equipment, key: ValueKey('equipment_${equipment.id}'));
|
||||
}).toList();
|
||||
} else {
|
||||
// Containers
|
||||
final filteredContainers = _paginatedContainers.where((container) {
|
||||
if (!_showConflictingItems) {
|
||||
// Vérifier si le container lui-même est en conflit
|
||||
if (_conflictingContainerIds.contains(container.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasEquipmentOfCategory) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// Vérifier si le container a des équipements enfants en conflit
|
||||
final hasConflictingChildren = container.equipmentIds.any(
|
||||
(eqId) => _conflictingEquipmentIds.contains(eqId),
|
||||
);
|
||||
|
||||
// Filtre par recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final searchLower = _searchQuery.toLowerCase();
|
||||
return container.id.toLowerCase().contains(searchLower) ||
|
||||
container.name.toLowerCase().contains(searchLower);
|
||||
}
|
||||
if (hasConflictingChildren) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}).toList();
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
itemWidgets = filteredContainers.map((container) {
|
||||
return _buildContainerCard(container, key: ValueKey('container_${container.id}'));
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// Filtrage des équipements (TOUS, pas seulement les orphelins)
|
||||
final filteredEquipment = allEquipment.where((eq) {
|
||||
// Filtre par conflit (masquer si non cochée et en conflit)
|
||||
if (!_showConflictingItems && _conflictingEquipmentIds.contains(eq.id)) {
|
||||
return false;
|
||||
}
|
||||
return ListView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header
|
||||
_buildSectionHeader(
|
||||
_displayType == SelectionType.equipment ? 'Équipements' : 'Containers',
|
||||
_displayType == SelectionType.equipment ? Icons.inventory_2 : Icons.inventory,
|
||||
itemWidgets.length,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Filtre par catégorie
|
||||
if (_selectedCategory != null && eq.category != _selectedCategory) {
|
||||
return false;
|
||||
}
|
||||
// Items
|
||||
...itemWidgets,
|
||||
|
||||
// Filtre par recherche
|
||||
if (_searchQuery.isNotEmpty) {
|
||||
final searchLower = _searchQuery.toLowerCase();
|
||||
return eq.id.toLowerCase().contains(searchLower) ||
|
||||
(eq.brand?.toLowerCase().contains(searchLower) ?? false) ||
|
||||
(eq.model?.toLowerCase().contains(searchLower) ?? false);
|
||||
}
|
||||
// Indicateur de chargement en bas
|
||||
if (_isLoadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(color: AppColors.rouge),
|
||||
),
|
||||
),
|
||||
|
||||
return true;
|
||||
}).toList();
|
||||
// Message si fin de liste
|
||||
if (!_isLoadingMore && !(_displayType == SelectionType.equipment ? _hasMoreEquipments : _hasMoreContainers))
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Fin de la liste',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
return ListView(
|
||||
controller: _scrollController, // Préserve la position de scroll
|
||||
padding: const EdgeInsets.all(16),
|
||||
cacheExtent: 1000, // Cache plus d'items pour éviter les rebuilds lors du scroll
|
||||
children: [
|
||||
// SECTION 1 : BOÎTES
|
||||
if (filteredContainers.isNotEmpty) ...[
|
||||
_buildCollapsibleSectionHeader(
|
||||
'Boîtes',
|
||||
Icons.inventory,
|
||||
filteredContainers.length,
|
||||
_containersExpanded,
|
||||
(expanded) {
|
||||
setState(() {
|
||||
_containersExpanded = expanded;
|
||||
});
|
||||
},
|
||||
// Message si rien trouvé
|
||||
if (itemWidgets.isEmpty && !_isLoadingMore)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun résultat trouvé',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_containersExpanded) ...[
|
||||
...filteredContainers.map((container) => _buildContainerCard(
|
||||
container,
|
||||
key: ValueKey('container_${container.id}'),
|
||||
)),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
],
|
||||
|
||||
// SECTION 2 : TOUS LES ÉQUIPEMENTS
|
||||
if (filteredEquipment.isNotEmpty) ...[
|
||||
_buildCollapsibleSectionHeader(
|
||||
'Tous les équipements',
|
||||
Icons.inventory_2,
|
||||
filteredEquipment.length,
|
||||
_equipmentExpanded,
|
||||
(expanded) {
|
||||
setState(() {
|
||||
_equipmentExpanded = expanded;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (_equipmentExpanded) ...[
|
||||
...filteredEquipment.map((equipment) => _buildEquipmentCard(
|
||||
equipment,
|
||||
key: ValueKey('equipment_${equipment.id}'),
|
||||
)),
|
||||
],
|
||||
],
|
||||
|
||||
// Message si rien n'est trouvé
|
||||
if (filteredContainers.isEmpty && filteredEquipment.isEmpty)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.search_off, size: 64, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Aucun résultat trouvé',
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
); // Fin du ValueListenableBuilder
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1873,10 +2014,10 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${container.itemCount} équipement(s)',
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.blue.shade700,
|
||||
color: Colors.blue,
|
||||
),
|
||||
),
|
||||
if (hasConflict) ...[
|
||||
@@ -1965,68 +2106,65 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
|
||||
/// Widget pour afficher les équipements enfants d'un conteneur
|
||||
Widget _buildContainerChildren(ContainerModel container, ContainerConflictInfo? conflictInfo) {
|
||||
return Consumer<EquipmentProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return StreamBuilder<List<EquipmentModel>>(
|
||||
stream: provider.equipmentStream,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
// Utiliser les équipements paginés et le cache
|
||||
final allEquipment = [..._paginatedEquipments, ..._cachedEquipment];
|
||||
final childEquipments = allEquipment
|
||||
.where((eq) => container.equipmentIds.contains(eq.id))
|
||||
.toList();
|
||||
|
||||
final allEquipment = snapshot.data ?? [];
|
||||
final childEquipments = allEquipment
|
||||
.where((eq) => container.equipmentIds.contains(eq.id))
|
||||
.toList();
|
||||
DebugLog.info('[EquipmentSelectionDialog] Building container children for ${container.id}: ${container.equipmentIds.length} IDs, found ${childEquipments.length} equipment(s) in cache (total cache: ${_cachedEquipment.length})');
|
||||
|
||||
if (childEquipments.isEmpty) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
if (container.equipmentIds.isNotEmpty && childEquipments.isEmpty) {
|
||||
DebugLog.error('[EquipmentSelectionDialog] Container ${container.id} has ${container.equipmentIds.length} equipment IDs but found 0 equipment in cache!');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Looking for IDs: ${container.equipmentIds.take(5).join(", ")}...');
|
||||
DebugLog.info('[EquipmentSelectionDialog] Cache contains IDs: ${_cachedEquipment.take(5).map((e) => e.id).join(", ")}...');
|
||||
}
|
||||
|
||||
if (childEquipments.isEmpty) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Aucun équipement dans ce conteneur',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.list, size: 16, color: Colors.grey.shade700),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Contenu de la boîte :',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.info_outline, size: 16, color: Colors.grey.shade600),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'Aucun équipement dans ce conteneur',
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade300)),
|
||||
),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.list, size: 16, color: Colors.grey.shade700),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Contenu de la boîte :',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...childEquipments.map((eq) {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...childEquipments.map((eq) {
|
||||
final hasConflict = _equipmentConflicts.containsKey(eq.id);
|
||||
final conflicts = _equipmentConflicts[eq.id] ?? [];
|
||||
|
||||
@@ -2115,10 +2253,6 @@ class _EquipmentSelectionDialogState extends State<EquipmentSelectionDialog> {
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionPanel() {
|
||||
|
||||
@@ -156,212 +156,9 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Found ${newEquipment.length} equipment(s) and ${newContainers.length} container(s)');
|
||||
|
||||
// Charger les équipements et conteneurs
|
||||
final containerProvider = context.read<ContainerProvider>();
|
||||
final equipmentProvider = context.read<EquipmentProvider>();
|
||||
// ✅ Pas de vérification de conflits : déjà fait dans le pop-up
|
||||
// On enregistre directement la sélection
|
||||
|
||||
final allContainers = await containerProvider.containersStream.first;
|
||||
final allEquipment = await equipmentProvider.equipmentStream.first;
|
||||
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Starting conflict checks...');
|
||||
final allConflicts = <String, List<AvailabilityConflict>>{};
|
||||
|
||||
// 1. Vérifier les conflits pour les équipements directs
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newEquipment.length} equipment(s)');
|
||||
for (var eq in newEquipment) {
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Checking equipment: ${eq.equipmentId}');
|
||||
|
||||
final equipment = allEquipment.firstWhere(
|
||||
(e) => e.id == eq.equipmentId,
|
||||
orElse: () => EquipmentModel(
|
||||
id: eq.equipmentId,
|
||||
name: 'Inconnu',
|
||||
category: EquipmentCategory.other,
|
||||
status: EquipmentStatus.available,
|
||||
maintenanceIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: hasQuantity=${equipment.hasQuantity}');
|
||||
|
||||
// Pour les équipements quantifiables (consommables/câbles)
|
||||
if (equipment.hasQuantity) {
|
||||
// Vérifier la quantité disponible
|
||||
final availableQty = await _availabilityService.getAvailableQuantity(
|
||||
equipment: equipment,
|
||||
startDate: widget.startDate!,
|
||||
endDate: widget.endDate!,
|
||||
excludeEventId: widget.eventId,
|
||||
);
|
||||
|
||||
// ⚠️ Ne créer un conflit QUE si la quantité demandée est supérieure à la quantité disponible
|
||||
if (eq.quantity > availableQty) {
|
||||
// Il y a vraiment un conflit de quantité
|
||||
final conflicts = await _availabilityService.checkEquipmentAvailabilityWithQuantity(
|
||||
equipment: equipment,
|
||||
requestedQuantity: eq.quantity,
|
||||
startDate: widget.startDate!,
|
||||
endDate: widget.endDate!,
|
||||
excludeEventId: widget.eventId,
|
||||
);
|
||||
|
||||
// Ne garder que les conflits réels (quand il n'y a pas assez de stock)
|
||||
if (conflicts.isNotEmpty) {
|
||||
allConflicts[eq.equipmentId] = conflicts;
|
||||
}
|
||||
}
|
||||
// ✅ Sinon, pas de conflit : il y a assez de stock disponible
|
||||
} else {
|
||||
// Pour les équipements non quantifiables (vérification classique)
|
||||
final conflicts = await _availabilityService.checkEquipmentAvailability(
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name,
|
||||
startDate: widget.startDate!,
|
||||
endDate: widget.endDate!,
|
||||
excludeEventId: widget.eventId,
|
||||
);
|
||||
|
||||
if (conflicts.isNotEmpty) {
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: ${conflicts.length} conflict(s) found');
|
||||
allConflicts[eq.equipmentId] = conflicts;
|
||||
} else {
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Equipment ${eq.equipmentId}: no conflicts');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Vérifier les conflits pour les boîtes et leur contenu
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Checking conflicts for ${newContainers.length} container(s)');
|
||||
for (var containerId in newContainers) {
|
||||
final container = allContainers.firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
orElse: () => ContainerModel(
|
||||
id: containerId,
|
||||
name: 'Inconnu',
|
||||
type: ContainerType.flightCase,
|
||||
status: EquipmentStatus.available,
|
||||
equipmentIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
|
||||
// Récupérer les équipements de la boîte
|
||||
final containerEquipment = container.equipmentIds
|
||||
.map((eqId) => allEquipment.firstWhere(
|
||||
(e) => e.id == eqId,
|
||||
orElse: () => EquipmentModel(
|
||||
id: eqId,
|
||||
name: 'Inconnu',
|
||||
category: EquipmentCategory.other,
|
||||
status: EquipmentStatus.available,
|
||||
maintenanceIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
))
|
||||
.toList();
|
||||
|
||||
// Vérifier chaque équipement de la boîte individuellement
|
||||
final containerConflicts = <AvailabilityConflict>[];
|
||||
|
||||
for (var equipment in containerEquipment) {
|
||||
if (equipment.hasQuantity) {
|
||||
// Pour les consommables/câbles, vérifier la quantité disponible
|
||||
final availableQty = await _availabilityService.getAvailableQuantity(
|
||||
equipment: equipment,
|
||||
startDate: widget.startDate!,
|
||||
endDate: widget.endDate!,
|
||||
excludeEventId: widget.eventId,
|
||||
);
|
||||
|
||||
// La boîte contient 1 unité de cet équipement
|
||||
// Si la quantité disponible est insuffisante, créer un conflit
|
||||
if (availableQty < 1) {
|
||||
final conflicts = await _availabilityService.checkEquipmentAvailability(
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name,
|
||||
startDate: widget.startDate!,
|
||||
endDate: widget.endDate!,
|
||||
excludeEventId: widget.eventId,
|
||||
);
|
||||
containerConflicts.addAll(conflicts);
|
||||
}
|
||||
} else {
|
||||
// Pour les équipements non quantifiables
|
||||
final conflicts = await _availabilityService.checkEquipmentAvailability(
|
||||
equipmentId: equipment.id,
|
||||
equipmentName: equipment.name,
|
||||
startDate: widget.startDate!,
|
||||
endDate: widget.endDate!,
|
||||
excludeEventId: widget.eventId,
|
||||
);
|
||||
containerConflicts.addAll(conflicts);
|
||||
}
|
||||
}
|
||||
|
||||
if (containerConflicts.isNotEmpty) {
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: ${containerConflicts.length} conflict(s) found');
|
||||
allConflicts[containerId] = containerConflicts;
|
||||
} else {
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Container $containerId: no conflicts');
|
||||
}
|
||||
}
|
||||
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Total conflicts found: ${allConflicts.length}');
|
||||
|
||||
if (allConflicts.isNotEmpty) {
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Showing conflict dialog with ${allConflicts.length} items in conflict');
|
||||
// Afficher le dialog de conflits
|
||||
final action = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => EquipmentConflictDialog(conflicts: allConflicts),
|
||||
);
|
||||
|
||||
DebugLog.info('[EventAssignedEquipmentSection] Conflict dialog result: $action');
|
||||
|
||||
if (action == 'cancel') {
|
||||
return; // Annuler l'ajout
|
||||
} else if (action == 'force_removed') {
|
||||
// Identifier quels équipements/conteneurs retirer
|
||||
final removedIds = allConflicts.keys.toSet();
|
||||
|
||||
// Retirer les équipements directs en conflit
|
||||
newEquipment.removeWhere((eq) => removedIds.contains(eq.equipmentId));
|
||||
|
||||
// Retirer les boîtes en conflit
|
||||
newContainers.removeWhere((containerId) => removedIds.contains(containerId));
|
||||
|
||||
// Informer l'utilisateur des boîtes retirées
|
||||
for (var containerId in removedIds.where((id) => newContainers.contains(id))) {
|
||||
if (mounted) {
|
||||
final container = allContainers.firstWhere(
|
||||
(c) => c.id == containerId,
|
||||
orElse: () => ContainerModel(
|
||||
id: containerId,
|
||||
name: 'Inconnu',
|
||||
type: ContainerType.flightCase,
|
||||
status: EquipmentStatus.available,
|
||||
equipmentIds: [],
|
||||
createdAt: DateTime.now(),
|
||||
updatedAt: DateTime.now(),
|
||||
),
|
||||
);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('La boîte "${container.name}" a été retirée en raison de conflits.'),
|
||||
backgroundColor: Colors.orange,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Si 'force_all', on garde tout
|
||||
}
|
||||
|
||||
// Fusionner avec l'existant
|
||||
final updatedEquipment = [...widget.assignedEquipment];
|
||||
final updatedContainers = [...widget.assignedContainers];
|
||||
@@ -398,7 +195,7 @@ class _EventAssignedEquipmentSectionState extends State<EventAssignedEquipmentSe
|
||||
|
||||
// Recharger le cache
|
||||
await _loadEquipmentAndContainers();
|
||||
}
|
||||
}
|
||||
|
||||
void _removeEquipment(String equipmentId) {
|
||||
final updated = widget.assignedEquipment
|
||||
|
||||
@@ -9,12 +9,14 @@ class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||
final String title;
|
||||
final List<Widget>? actions;
|
||||
final bool showLogoutButton;
|
||||
final Widget? leading;
|
||||
|
||||
const CustomAppBar({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.actions,
|
||||
this.showLogoutButton = true,
|
||||
this.leading,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -30,6 +32,7 @@ class _CustomAppBarState extends State<CustomAppBar> {
|
||||
return AppBar(
|
||||
title: Text(widget.title),
|
||||
backgroundColor: AppColors.rouge,
|
||||
leading: widget.leading,
|
||||
actions: [
|
||||
NotificationBadge(),
|
||||
if (widget.showLogoutButton)
|
||||
|
||||
Reference in New Issue
Block a user