feat: recherche d'événements et gestion avancée de la suppression d'équipement
- **Recherche d'événements** : Ajout d'une fonctionnalité de recherche (titre, description, lieu) dans le calendrier et d'une nouvelle fonction Cloud `searchEvents` avec gestion des permissions.
- **Suppression d'équipement avec forçage** :
- Mise à jour de la fonction Cloud `deleteEquipment` pour détecter les assignations à des événements futurs.
- Ajout d'une option `forceDelete` pour passer outre les conflits d'assignation.
- Création de `EquipmentDeleteUtils` pour gérer uniformément les dialogues de confirmation et les erreurs de conflit (HTTP 409).
- Intégration de la logique de suppression sécurisée dans `EquipmentDetailPage` et `EquipmentManagementPage`.
- **Calendrier** :
- Refonte de l'interface mobile pour intégrer la barre de recherche.
- Optimisation du chargement des événements lors de la sélection d'un résultat de recherche (lazy loading du mois concerné).
- Amélioration de la stabilité de la sélection d'événements et du filtrage par utilisateur.
- **Services & Providers** :
- Amélioration de la gestion des erreurs dans `ApiService` pour faciliter le re-throw des exceptions personnalisées.
- Ajout du support de la suppression forcée dans `DataService` et `EquipmentProvider`.
- **Refactoring** : Nettoyage du code, amélioration du formatage et ajout de logs de debug dans les services de données et d'équipements.
This commit is contained in:
+140
-8
@@ -203,29 +203,51 @@ exports.deleteEquipment = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { equipmentId } = req.body.data;
|
||||
const { equipmentId, forceDelete = false } = req.body.data;
|
||||
|
||||
if (!equipmentId) {
|
||||
res.status(400).json({ error: 'Equipment ID is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Vérifier si l'équipement est utilisé dans des événements actifs
|
||||
// Vérifier si l'équipement est utilisé dans des événements à venir
|
||||
const eventsSnapshot = await db.collection('events')
|
||||
.where('status', '!=', 'CANCELLED')
|
||||
.get();
|
||||
|
||||
const now = new Date();
|
||||
const upcomingEvents = [];
|
||||
|
||||
for (const eventDoc of eventsSnapshot.docs) {
|
||||
const eventData = eventDoc.data();
|
||||
const assignedEquipment = eventData.assignedEquipment || [];
|
||||
|
||||
if (assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
|
||||
res.status(409).json({
|
||||
error: 'Cannot delete equipment: it is assigned to active events',
|
||||
eventId: eventDoc.id
|
||||
});
|
||||
return;
|
||||
if (!assignedEquipment.some(eq => eq.equipmentId === equipmentId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let eventStart = null;
|
||||
if (eventData.StartDateTime) {
|
||||
eventStart = eventData.StartDateTime.toDate
|
||||
? eventData.StartDateTime.toDate()
|
||||
: new Date(eventData.StartDateTime);
|
||||
}
|
||||
|
||||
if (eventStart && eventStart > now) {
|
||||
upcomingEvents.push({
|
||||
eventId: eventDoc.id,
|
||||
eventName: eventData.Name || '',
|
||||
startDate: eventStart.toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (upcomingEvents.length > 0 && !forceDelete) {
|
||||
res.status(409).json({
|
||||
error: 'FUTURE_EVENT_ASSIGNMENT: Cannot delete equipment because it is assigned to upcoming events',
|
||||
upcomingEvents,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await db.collection('equipments').doc(equipmentId).delete();
|
||||
@@ -1864,6 +1886,116 @@ exports.getEventsByMonth = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
}
|
||||
}));
|
||||
|
||||
const normalizeSearchText = (value) => {
|
||||
return (value || '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim();
|
||||
};
|
||||
|
||||
const getEventStartDate = (eventData) => {
|
||||
const startValue = eventData.StartDateTime;
|
||||
|
||||
if (!startValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (startValue.toDate) {
|
||||
return startValue.toDate();
|
||||
}
|
||||
|
||||
const parsedDate = new Date(startValue);
|
||||
return Number.isNaN(parsedDate.getTime()) ? null : parsedDate;
|
||||
};
|
||||
|
||||
const getEventWorkforceUids = (eventData) => {
|
||||
if (!eventData.workforce || !Array.isArray(eventData.workforce)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return eventData.workforce
|
||||
.map((userRef) => {
|
||||
if (userRef && userRef.id) {
|
||||
return userRef.id;
|
||||
}
|
||||
|
||||
if (typeof userRef === 'string' && userRef.startsWith('users/')) {
|
||||
return userRef.split('/')[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
.filter((uid) => uid !== null);
|
||||
};
|
||||
|
||||
const serializeEventSearchResult = (doc) => {
|
||||
const data = doc.data();
|
||||
|
||||
return {
|
||||
id: doc.id,
|
||||
...helpers.serializeTimestamps(data),
|
||||
workforce: getEventWorkforceUids(data),
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// EVENTS - Search
|
||||
// ============================================================================
|
||||
exports.searchEvents = onRequest(httpOptions, withCors(async (req, res) => {
|
||||
try {
|
||||
const decodedToken = await auth.authenticateUser(req);
|
||||
const { userId, query, limit = 20 } = req.body.data || {};
|
||||
const maxResults = Number.isFinite(Number(limit)) ? Math.max(1, Number(limit)) : 20;
|
||||
|
||||
const normalizedQuery = normalizeSearchText(query);
|
||||
if (!normalizedQuery) {
|
||||
res.status(200).json({ events: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const canViewAll = await auth.hasPermission(decodedToken.uid, 'view_all_events');
|
||||
|
||||
let eventsSnapshot;
|
||||
if (canViewAll) {
|
||||
eventsSnapshot = await db.collection('events').get();
|
||||
} else {
|
||||
const userRef = db.collection('users').doc(userId || decodedToken.uid);
|
||||
eventsSnapshot = await db.collection('events')
|
||||
.where('workforce', 'array-contains', userRef)
|
||||
.get();
|
||||
}
|
||||
|
||||
const matchingEvents = eventsSnapshot.docs
|
||||
.filter((doc) => {
|
||||
const eventData = doc.data();
|
||||
const startDate = getEventStartDate(eventData);
|
||||
const searchableText = normalizeSearchText([
|
||||
eventData.Name,
|
||||
eventData.Description,
|
||||
eventData.Address,
|
||||
startDate ? startDate.toLocaleString('fr-FR') : '',
|
||||
startDate ? startDate.toISOString() : '',
|
||||
].join(' '));
|
||||
|
||||
return searchableText.includes(normalizedQuery);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
const startA = getEventStartDate(a.data()) || new Date(0);
|
||||
const startB = getEventStartDate(b.data()) || new Date(0);
|
||||
return startA.getTime() - startB.getTime();
|
||||
})
|
||||
.slice(0, maxResults)
|
||||
.map((doc) => serializeEventSearchResult(doc));
|
||||
|
||||
res.status(200).json({ events: matchingEvents });
|
||||
} catch (error) {
|
||||
logger.error('Error searching events:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}));
|
||||
|
||||
/**
|
||||
* Récupère un événement avec tous les détails (équipements complets + containers avec enfants)
|
||||
* Optimisé pour la page de préparation et l'affichage détaillé
|
||||
|
||||
Reference in New Issue
Block a user