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:
ElPoyo
2026-04-22 12:21:13 +02:00
parent 0551f0b9c1
commit eac103491f
14 changed files with 1309 additions and 341 deletions
+140 -8
View File
@@ -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é