diff --git a/loko/common/locale/en/LC_MESSAGES/django.mo b/loko/common/locale/en/LC_MESSAGES/django.mo index b0c1c16..762ea3c 100644 Binary files a/loko/common/locale/en/LC_MESSAGES/django.mo and b/loko/common/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/common/locale/en/LC_MESSAGES/django.po b/loko/common/locale/en/LC_MESSAGES/django.po index 8264b88..700b9c3 100644 --- a/loko/common/locale/en/LC_MESSAGES/django.po +++ b/loko/common/locale/en/LC_MESSAGES/django.po @@ -560,3 +560,19 @@ msgstr "Reset" msgid "Format de fond de plan invalide" msgstr "Invalid basemap format" +#: common/views.py:863 +msgid "Module invalide" +msgstr "Invalid module" + +#: common/views.py:874 +msgid "Le nom du filtre est obligatoire" +msgstr "Filter name is required" + +#: common/views.py:937 +msgid "Identifiant de filtre manquant" +msgstr "Missing filter ID" + +#: common/models.py:382 +msgid "Filtres favoris" +msgstr "Favorite filters" + diff --git a/loko/common/locale/fr/LC_MESSAGES/django.mo b/loko/common/locale/fr/LC_MESSAGES/django.mo index 6d41700..66d1cde 100644 Binary files a/loko/common/locale/fr/LC_MESSAGES/django.mo and b/loko/common/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/common/locale/fr/LC_MESSAGES/django.po b/loko/common/locale/fr/LC_MESSAGES/django.po index 26aab10..acd0d1c 100644 --- a/loko/common/locale/fr/LC_MESSAGES/django.po +++ b/loko/common/locale/fr/LC_MESSAGES/django.po @@ -546,3 +546,19 @@ msgstr "Réinitialiser" msgid "Format de fond de plan invalide" msgstr "Format de fond de plan invalide" +#: common/views.py:863 +msgid "Module invalide" +msgstr "Module invalide" + +#: common/views.py:874 +msgid "Le nom du filtre est obligatoire" +msgstr "Le nom du filtre est obligatoire" + +#: common/views.py:937 +msgid "Identifiant de filtre manquant" +msgstr "Identifiant de filtre manquant" + +#: common/models.py:382 +msgid "Filtres favoris" +msgstr "Filtres favoris" + diff --git a/loko/common/locale/nl/LC_MESSAGES/django.mo b/loko/common/locale/nl/LC_MESSAGES/django.mo index 55ed8b0..631a00a 100644 Binary files a/loko/common/locale/nl/LC_MESSAGES/django.mo and b/loko/common/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/common/locale/nl/LC_MESSAGES/django.po b/loko/common/locale/nl/LC_MESSAGES/django.po index ab11c32..3eefea6 100644 --- a/loko/common/locale/nl/LC_MESSAGES/django.po +++ b/loko/common/locale/nl/LC_MESSAGES/django.po @@ -546,3 +546,19 @@ msgstr "Herstellen" msgid "Format de fond de plan invalide" msgstr "Ongeldig basiskaartformaat" +#: common/views.py:863 +msgid "Module invalide" +msgstr "Ongeldige module" + +#: common/views.py:874 +msgid "Le nom du filtre est obligatoire" +msgstr "Filternaam is verplicht" + +#: common/views.py:937 +msgid "Identifiant de filtre manquant" +msgstr "Filter-ID ontbreekt" + +#: common/models.py:382 +msgid "Filtres favoris" +msgstr "Favoriete filters" + diff --git a/loko/common/migrations/0021_userconfig_favorite_filters.py b/loko/common/migrations/0021_userconfig_favorite_filters.py new file mode 100644 index 0000000..0700c0f --- /dev/null +++ b/loko/common/migrations/0021_userconfig_favorite_filters.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.7 on 2026-10-12 12:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('common', '0020_userconfig_can_view_all_posts_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='userconfig', + name='favorite_filters', + field=models.JSONField(blank=True, default=dict, help_text='Filtres favoris enregistrés par module (interventions, observations, assets, etc.).', verbose_name='Filtres favoris'), + ), + ] diff --git a/loko/common/models.py b/loko/common/models.py index 5a9e196..89ad4e0 100644 --- a/loko/common/models.py +++ b/loko/common/models.py @@ -377,6 +377,12 @@ class UserConfig(models.Model): verbose_name="Préférences du calendrier", help_text="Préférences de la page calendrier (mode d'affichage, zoom, filtres actifs, état d'expansion du backlog).", ) + favorite_filters = models.JSONField( + default=dict, + blank=True, + verbose_name="Filtres favoris", + help_text="Filtres favoris enregistrés par module (interventions, observations, assets, etc.).", + ) def __str__(self): return f"Configuration de {self.user.username}" diff --git a/loko/common/tests.py b/loko/common/tests.py index 296cc2f..5f8ff0d 100644 --- a/loko/common/tests.py +++ b/loko/common/tests.py @@ -665,6 +665,81 @@ class HelpDocumentTests(TestCase): self.assertNotIn("StreetUp", rendered_nl) +class FavoriteFiltersAPITests(TestCase): + def setUp(self): + self.user = User.objects.create_user(username="fav_tester", password="password123") + self.config = UserConfig.objects.create(user=self.user) + self.client.force_login(self.user) + + def test_save_and_update_favorite_filter(self): + # 1. Create a new favorite filter + url = reverse("common:save_favorite_filter", kwargs={"module": "interventions"}) + payload = { + "name": "Urgences Voirie", + "filters": { + "thematic": ["road"], + "priority": "high", + "status": ["in_progress"], + }, + } + response = self.client.post(url, json.dumps(payload), content_type="application/json") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data["success"]) + fav = data["favorite"] + self.assertEqual(fav["name"], "Urgences Voirie") + self.assertIn("id", fav) + self.assertEqual(len(data["favorites"]), 1) + + # Verify persistence in UserConfig + self.config.refresh_from_db() + interventions_favs = self.config.favorite_filters.get("interventions", []) + self.assertEqual(len(interventions_favs), 1) + self.assertEqual(interventions_favs[0]["name"], "Urgences Voirie") + + # 2. Update the existing favorite filter + fav_id = fav["id"] + update_payload = { + "id": fav_id, + "name": "Urgences Voirie Modifiées", + "filters": { + "thematic": ["road"], + "priority": "high", + "status": ["in_progress", "assigned"], + }, + } + update_response = self.client.post(url, json.dumps(update_payload), content_type="application/json") + self.assertEqual(update_response.status_code, 200) + update_data = update_response.json() + self.assertEqual(update_data["favorite"]["name"], "Urgences Voirie Modifiées") + self.assertEqual(len(update_data["favorites"]), 1) + + # 3. Create a second favorite + second_payload = { + "name": "Espaces verts", + "filters": {"thematic": ["green"]}, + } + self.client.post(url, json.dumps(second_payload), content_type="application/json") + self.config.refresh_from_db() + self.assertEqual(len(self.config.favorite_filters["interventions"]), 2) + + # 4. Delete the first favorite + delete_url = reverse("common:delete_favorite_filter", kwargs={"module": "interventions"}) + delete_response = self.client.post(delete_url, json.dumps({"id": fav_id}), content_type="application/json") + self.assertEqual(delete_response.status_code, 200) + delete_data = delete_response.json() + self.assertEqual(len(delete_data["favorites"]), 1) + self.assertEqual(delete_data["favorites"][0]["name"], "Espaces verts") + + self.config.refresh_from_db() + self.assertEqual(len(self.config.favorite_filters["interventions"]), 1) + + def test_invalid_module_rejected(self): + url = reverse("common:save_favorite_filter", kwargs={"module": "unknown_module"}) + response = self.client.post(url, json.dumps({"name": "Test", "filters": {}}), content_type="application/json") + self.assertEqual(response.status_code, 400) + + diff --git a/loko/common/urls.py b/loko/common/urls.py index 4747429..b4667c4 100644 --- a/loko/common/urls.py +++ b/loko/common/urls.py @@ -20,5 +20,7 @@ urlpatterns = [ path("save-assets-index-preferences/", views.save_assets_index_preferences, name="save_assets_index_preferences"), path("save-calendar-preferences/", views.save_calendar_preferences, name="save_calendar_preferences"), path("save-project-list-preferences/", views.save_project_list_preferences, name="save_project_list_preferences"), + path("favorite-filters//save/", views.save_favorite_filter, name="save_favorite_filter"), + path("favorite-filters//delete/", views.delete_favorite_filter, name="delete_favorite_filter"), path("help//", views.get_help_document, name="get_help_document"), ] \ No newline at end of file diff --git a/loko/common/user_services.py b/loko/common/user_services.py index a424897..57ed05a 100644 --- a/loko/common/user_services.py +++ b/loko/common/user_services.py @@ -167,6 +167,7 @@ def copy_user_config(source_user_or_config: Any, target_user_or_config: Any, cop target_config.calendar_preferences = ( dict(source_config.calendar_preferences) if source_config.calendar_preferences is not None else None ) + target_config.favorite_filters = dict(source_config.favorite_filters or {}) # Permissions spécifiques (eSign, documents, panoramax, transport exceptionnel, contrats) target_config.can_add_unique_tag_documents = source_config.can_add_unique_tag_documents diff --git a/loko/common/views.py b/loko/common/views.py index 984a718..8a13e45 100644 --- a/loko/common/views.py +++ b/loko/common/views.py @@ -852,5 +852,119 @@ def set_language(request): return response +ALLOWED_FAVORITE_MODULES = [ + 'interventions', + 'observations', + 'assets', + 'controls', + 'inspections', + 'projects', +] + + +@login_required +@require_POST +def save_favorite_filter(request, module): + """API endpoint to save or update a favorite filter for a specific module.""" + import json + import uuid + from django.utils import timezone + + if module not in ALLOWED_FAVORITE_MODULES: + return JsonResponse({'error': _('Module invalide')}, status=400) + + try: + data = json.loads(request.body) + name = (data.get('name') or '').strip() + filters = data.get('filters', {}) + favorite_id = data.get('id') + except (json.JSONDecodeError, AttributeError): + return JsonResponse({'error': _('Données invalides')}, status=400) + + if not name: + return JsonResponse({'error': _('Le nom du filtre est obligatoire')}, status=400) + + if not isinstance(filters, dict): + return JsonResponse({'error': _('Format de filtres invalide')}, status=400) + + try: + user_config, _created = UserConfig.objects.get_or_create(user=request.user) + all_favorites = dict(user_config.favorite_filters or {}) + module_favorites = list(all_favorites.get(module, [])) + + now_str = timezone.now().isoformat() + saved_item = None + + if favorite_id: + # Update existing + for item in module_favorites: + if item.get('id') == favorite_id: + item['name'] = name + item['filters'] = filters + item['updated_at'] = now_str + saved_item = item + break + + if not saved_item: + # Create new + new_id = f"fav_{uuid.uuid4().hex[:12]}" + saved_item = { + 'id': new_id, + 'name': name, + 'filters': filters, + 'created_at': now_str, + } + module_favorites.append(saved_item) + + all_favorites[module] = module_favorites + user_config.favorite_filters = all_favorites + user_config.save(update_fields=['favorite_filters']) + + return JsonResponse({ + 'success': True, + 'favorite': saved_item, + 'favorites': module_favorites, + }) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + +@login_required +@require_POST +def delete_favorite_filter(request, module): + """API endpoint to delete a favorite filter for a specific module.""" + import json + + if module not in ALLOWED_FAVORITE_MODULES: + return JsonResponse({'error': _('Module invalide')}, status=400) + + try: + data = json.loads(request.body) + favorite_id = data.get('id') + except (json.JSONDecodeError, AttributeError): + return JsonResponse({'error': _('Données invalides')}, status=400) + + if not favorite_id: + return JsonResponse({'error': _('Identifiant de filtre manquant')}, status=400) + + try: + user_config, _created = UserConfig.objects.get_or_create(user=request.user) + all_favorites = dict(user_config.favorite_filters or {}) + module_favorites = list(all_favorites.get(module, [])) + + updated_favorites = [item for item in module_favorites if item.get('id') != favorite_id] + + all_favorites[module] = updated_favorites + user_config.favorite_filters = all_favorites + user_config.save(update_fields=['favorite_filters']) + + return JsonResponse({ + 'success': True, + 'favorites': updated_favorites, + }) + except Exception as e: + return JsonResponse({'error': str(e)}, status=500) + + diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.mo b/loko/interventions/locale/en/LC_MESSAGES/django.mo index f52a5cf..5252ef2 100644 Binary files a/loko/interventions/locale/en/LC_MESSAGES/django.mo and b/loko/interventions/locale/en/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/en/LC_MESSAGES/django.po b/loko/interventions/locale/en/LC_MESSAGES/django.po index 5f26f5a..e9d5ea4 100644 --- a/loko/interventions/locale/en/LC_MESSAGES/django.po +++ b/loko/interventions/locale/en/LC_MESSAGES/django.po @@ -8431,5 +8431,32 @@ msgstr "-- Select a role… --" msgid "-- Choisir une personne… --" msgstr "-- Choose a person… --" +msgid "— Sélectionner un favori —" +msgstr "— Select a favorite —" + +msgid "Enregistrer en favori" +msgstr "Save as favorite" + +msgid "Enregistrer comme favori" +msgstr "Save as favorite" + +msgid "Nom du filtre favori" +msgstr "Favorite filter name" + +msgid "Ex. Mes urgences voirie" +msgstr "Ex. My road emergencies" + +msgid "Donnez un nom clair pour retrouver facilement cette sélection de filtres." +msgstr "Give a clear name to easily find this selection of filters." + +msgid "Critères enregistrés :" +msgstr "Saved criteria:" + +msgid "Mes filtres favoris" +msgstr "My favorite filters" + +msgid "Enregistrer le filtre actuel..." +msgstr "Save current filter..." + diff --git a/loko/interventions/locale/en/LC_MESSAGES/djangojs.mo b/loko/interventions/locale/en/LC_MESSAGES/djangojs.mo index 0d9a148..7a77808 100644 Binary files a/loko/interventions/locale/en/LC_MESSAGES/djangojs.mo and b/loko/interventions/locale/en/LC_MESSAGES/djangojs.mo differ diff --git a/loko/interventions/locale/en/LC_MESSAGES/djangojs.po b/loko/interventions/locale/en/LC_MESSAGES/djangojs.po index ad52295..8a6fc48 100644 --- a/loko/interventions/locale/en/LC_MESSAGES/djangojs.po +++ b/loko/interventions/locale/en/LC_MESSAGES/djangojs.po @@ -1207,6 +1207,97 @@ msgstr "Error while saving" msgid "Erreur lors de la mise à jour" msgstr "Error during update" +msgid "Favoris" +msgstr "Favorites" + +msgid "Aucun favori enregistré" +msgstr "No saved favorites" + +msgid "Mettre à jour avec les filtres actuels" +msgstr "Update with current filters" + +msgid "Sélectionner un favori" +msgstr "Select a favorite" + +msgid "Aucun filtre sélectionné (toutes les interventions)." +msgstr "No filter selected (all interventions)." + +msgid "Thématique(s)" +msgstr "Thematic(s)" + +msgid "Statut(s)" +msgstr "Status(es)" + +msgid "Priorité" +msgstr "Priority" + +msgid "Type maintenance" +msgstr "Maintenance type" + +msgid "Catégories" +msgstr "Categories" + +msgid "sélectionnée(s)" +msgstr "selected" + +msgid "Date début" +msgstr "Start date" + +msgid "Date fin" +msgstr "End date" + +msgid "Non traitées (en retard)" +msgstr "Pending (overdue)" + +msgid "Suivies uniquement" +msgstr "Followed only" + +msgid "Marché bloqué : Oui" +msgstr "Contract blocked: Yes" + +msgid "Marché bloqué : Non" +msgstr "Contract blocked: No" + +msgid "Localisations" +msgstr "Locations" + +msgid "Filtres divers appliqués." +msgstr "Various filters applied." + +msgid "Filtre favori enregistré avec succès." +msgstr "Favorite filter saved successfully." + +msgid "Erreur lors de la sauvegarde du favori." +msgstr "Error saving favorite filter." + +msgid "Erreur de communication avec le serveur." +msgstr "Communication error with server." + +#, javascript-format +msgid "Voulez-vous vraiment supprimer le favori '%s' ?" +msgstr "Are you sure you want to delete favorite '%s'?" + +msgid "Filtre favori supprimé." +msgstr "Favorite filter deleted." + +msgid "Erreur lors de la suppression du favori." +msgstr "Error deleting favorite filter." + +#, javascript-format +msgid "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?" +msgstr "Update favorite '%s' with currently active filters?" + +#, javascript-format +msgid "Favori '%s' mis à jour." +msgstr "Favorite '%s' updated." + +msgid "Erreur lors de la mise à jour du favori." +msgstr "Error updating favorite filter." + +#, javascript-format +msgid "Filtre '%s' appliqué." +msgstr "Filter '%s' applied." + #~ msgid "Veuillez entrer un entier de maximum 6 chiffres" #~ msgstr "Please enter an integer with maximum 6 digits" diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.mo b/loko/interventions/locale/fr/LC_MESSAGES/django.mo index c399862..54b4a0f 100644 Binary files a/loko/interventions/locale/fr/LC_MESSAGES/django.mo and b/loko/interventions/locale/fr/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/fr/LC_MESSAGES/django.po b/loko/interventions/locale/fr/LC_MESSAGES/django.po index afd6c54..f57b1d7 100644 --- a/loko/interventions/locale/fr/LC_MESSAGES/django.po +++ b/loko/interventions/locale/fr/LC_MESSAGES/django.po @@ -8538,5 +8538,32 @@ msgstr "-- Sélectionner un rôle… --" msgid "-- Choisir une personne… --" msgstr "-- Choisir une personne… --" +msgid "— Sélectionner un favori —" +msgstr "— Sélectionner un favori —" + +msgid "Enregistrer en favori" +msgstr "Enregistrer en favori" + +msgid "Enregistrer comme favori" +msgstr "Enregistrer comme favori" + +msgid "Nom du filtre favori" +msgstr "Nom du filtre favori" + +msgid "Ex. Mes urgences voirie" +msgstr "Ex. Mes urgences voirie" + +msgid "Donnez un nom clair pour retrouver facilement cette sélection de filtres." +msgstr "Donnez un nom clair pour retrouver facilement cette sélection de filtres." + +msgid "Critères enregistrés :" +msgstr "Critères enregistrés :" + +msgid "Mes filtres favoris" +msgstr "Mes filtres favoris" + +msgid "Enregistrer le filtre actuel..." +msgstr "Enregistrer le filtre actuel..." + diff --git a/loko/interventions/locale/fr/LC_MESSAGES/djangojs.mo b/loko/interventions/locale/fr/LC_MESSAGES/djangojs.mo index 09fd1f5..238c680 100644 Binary files a/loko/interventions/locale/fr/LC_MESSAGES/djangojs.mo and b/loko/interventions/locale/fr/LC_MESSAGES/djangojs.mo differ diff --git a/loko/interventions/locale/fr/LC_MESSAGES/djangojs.po b/loko/interventions/locale/fr/LC_MESSAGES/djangojs.po index 8935bba..db25a87 100644 --- a/loko/interventions/locale/fr/LC_MESSAGES/djangojs.po +++ b/loko/interventions/locale/fr/LC_MESSAGES/djangojs.po @@ -1214,6 +1214,97 @@ msgstr "Erreur lors de la sauvegarde" msgid "Erreur lors de la mise à jour" msgstr "Erreur lors de la mise à jour" +msgid "Favoris" +msgstr "Favoris" + +msgid "Aucun favori enregistré" +msgstr "Aucun favori enregistré" + +msgid "Mettre à jour avec les filtres actuels" +msgstr "Mettre à jour avec les filtres actuels" + +msgid "Sélectionner un favori" +msgstr "Sélectionner un favori" + +msgid "Aucun filtre sélectionné (toutes les interventions)." +msgstr "Aucun filtre sélectionné (toutes les interventions)." + +msgid "Thématique(s)" +msgstr "Thématique(s)" + +msgid "Statut(s)" +msgstr "Statut(s)" + +msgid "Priorité" +msgstr "Priorité" + +msgid "Type maintenance" +msgstr "Type maintenance" + +msgid "Catégories" +msgstr "Catégories" + +msgid "sélectionnée(s)" +msgstr "sélectionnée(s)" + +msgid "Date début" +msgstr "Date début" + +msgid "Date fin" +msgstr "Date fin" + +msgid "Non traitées (en retard)" +msgstr "Non traitées (en retard)" + +msgid "Suivies uniquement" +msgstr "Suivies uniquement" + +msgid "Marché bloqué : Oui" +msgstr "Marché bloqué : Oui" + +msgid "Marché bloqué : Non" +msgstr "Marché bloqué : Non" + +msgid "Localisations" +msgstr "Localisations" + +msgid "Filtres divers appliqués." +msgstr "Filtres divers appliqués." + +msgid "Filtre favori enregistré avec succès." +msgstr "Filtre favori enregistré avec succès." + +msgid "Erreur lors de la sauvegarde du favori." +msgstr "Erreur lors de la sauvegarde du favori." + +msgid "Erreur de communication avec le serveur." +msgstr "Erreur de communication avec le serveur." + +#, javascript-format +msgid "Voulez-vous vraiment supprimer le favori '%s' ?" +msgstr "Voulez-vous vraiment supprimer le favori '%s' ?" + +msgid "Filtre favori supprimé." +msgstr "Filtre favori supprimé." + +msgid "Erreur lors de la suppression du favori." +msgstr "Erreur lors de la suppression du favori." + +#, javascript-format +msgid "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?" +msgstr "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?" + +#, javascript-format +msgid "Favori '%s' mis à jour." +msgstr "Favori '%s' mis à jour." + +msgid "Erreur lors de la mise à jour du favori." +msgstr "Erreur lors de la mise à jour du favori." + +#, javascript-format +msgid "Filtre '%s' appliqué." +msgstr "Filtre '%s' appliqué." + #~ msgid "Veuillez entrer un entier de maximum 6 chiffres" #~ msgstr "Veuillez entrer un entier de maximum 6 chiffres" diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.mo b/loko/interventions/locale/nl/LC_MESSAGES/django.mo index 30f0851..0870108 100644 Binary files a/loko/interventions/locale/nl/LC_MESSAGES/django.mo and b/loko/interventions/locale/nl/LC_MESSAGES/django.mo differ diff --git a/loko/interventions/locale/nl/LC_MESSAGES/django.po b/loko/interventions/locale/nl/LC_MESSAGES/django.po index fb4b5b1..80a019d 100644 --- a/loko/interventions/locale/nl/LC_MESSAGES/django.po +++ b/loko/interventions/locale/nl/LC_MESSAGES/django.po @@ -8472,4 +8472,31 @@ msgstr "-- Selecteer een rol… --" msgid "-- Choisir une personne… --" msgstr "-- Kies een persoon… --" +msgid "— Sélectionner un favori —" +msgstr "— Selecteer een favoriet —" + +msgid "Enregistrer en favori" +msgstr "Opslaan als favoriet" + +msgid "Enregistrer comme favori" +msgstr "Opslaan als favoriet" + +msgid "Nom du filtre favori" +msgstr "Naam van favoriet filter" + +msgid "Ex. Mes urgences voirie" +msgstr "Bijv. Mijn wegongevallen" + +msgid "Donnez un nom clair pour retrouver facilement cette sélection de filtres." +msgstr "Geef een duidelijke naam om deze filterselectie gemakkelijk terug te vinden." + +msgid "Critères enregistrés :" +msgstr "Opgeslagen criteria:" + +msgid "Mes filtres favoris" +msgstr "Mijn favoriete filters" + +msgid "Enregistrer le filtre actuel..." +msgstr "Huidige filter opslaan..." + diff --git a/loko/interventions/locale/nl/LC_MESSAGES/djangojs.mo b/loko/interventions/locale/nl/LC_MESSAGES/djangojs.mo index 4026aeb..50b59f3 100644 Binary files a/loko/interventions/locale/nl/LC_MESSAGES/djangojs.mo and b/loko/interventions/locale/nl/LC_MESSAGES/djangojs.mo differ diff --git a/loko/interventions/locale/nl/LC_MESSAGES/djangojs.po b/loko/interventions/locale/nl/LC_MESSAGES/djangojs.po index 7f11361..5f4ebae 100644 --- a/loko/interventions/locale/nl/LC_MESSAGES/djangojs.po +++ b/loko/interventions/locale/nl/LC_MESSAGES/djangojs.po @@ -1206,6 +1206,97 @@ msgstr "Fout bij het opslaan" msgid "Erreur lors de la mise à jour" msgstr "Fout bij het bijwerken" +msgid "Favoris" +msgstr "Favorieten" + +msgid "Aucun favori enregistré" +msgstr "Geen opgeslagen favorieten" + +msgid "Mettre à jour avec les filtres actuels" +msgstr "Bijwerken met huidige filters" + +msgid "Sélectionner un favori" +msgstr "Selecteer een favoriet" + +msgid "Aucun filtre sélectionné (toutes les interventions)." +msgstr "Geen filter geselecteerd (alle interventies)." + +msgid "Thématique(s)" +msgstr "Thema('s)" + +msgid "Statut(s)" +msgstr "Status(sen)" + +msgid "Priorité" +msgstr "Prioriteit" + +msgid "Type maintenance" +msgstr "Onderhoudstype" + +msgid "Catégories" +msgstr "Categorieën" + +msgid "sélectionnée(s)" +msgstr "geselecteerd" + +msgid "Date début" +msgstr "Startdatum" + +msgid "Date fin" +msgstr "Einddatum" + +msgid "Non traitées (en retard)" +msgstr "Niet behandeld (achterstallig)" + +msgid "Suivies uniquement" +msgstr "Alleen gevolgde" + +msgid "Marché bloqué : Oui" +msgstr "Overeenkomst geblokkeerd: Ja" + +msgid "Marché bloqué : Non" +msgstr "Overeenkomst geblokkeerd: Nee" + +msgid "Localisations" +msgstr "Locaties" + +msgid "Filtres divers appliqués." +msgstr "Diverse filters toegepast." + +msgid "Filtre favori enregistré avec succès." +msgstr "Favoriet filter succesvol opgeslagen." + +msgid "Erreur lors de la sauvegarde du favori." +msgstr "Fout bij het opslaan van het favoriete filter." + +msgid "Erreur de communication avec le serveur." +msgstr "Communicatiefout met server." + +#, javascript-format +msgid "Voulez-vous vraiment supprimer le favori '%s' ?" +msgstr "Weet u zeker dat u favoriet '%s' wilt verwijderen?" + +msgid "Filtre favori supprimé." +msgstr "Favoriet filter verwijderd." + +msgid "Erreur lors de la suppression du favori." +msgstr "Fout bij het verwijderen van het favoriete filter." + +#, javascript-format +msgid "Mettre à jour le favori '%s' avec les filtres actuellement actifs ?" +msgstr "Favoriet '%s' bijwerken met momenteel actieve filters?" + +#, javascript-format +msgid "Favori '%s' mis à jour." +msgstr "Favoriet '%s' bijgewerkt." + +msgid "Erreur lors de la mise à jour du favori." +msgstr "Fout bij het bijwerken van het favoriete filter." + +#, javascript-format +msgid "Filtre '%s' appliqué." +msgstr "Filter '%s' toegepast." + #~ msgid "Veuillez entrer un entier de maximum 6 chiffres" #~ msgstr "Voer een geheel getal van maximaal 6 cijfers in" diff --git a/loko/interventions/static/interventions/interventions_index.js b/loko/interventions/static/interventions/interventions_index.js index d9656e4..f814750 100644 --- a/loko/interventions/static/interventions/interventions_index.js +++ b/loko/interventions/static/interventions/interventions_index.js @@ -314,6 +314,10 @@ document.getElementById('apply-filters').addEventListener('click', async functio if (filterModal) { filterModal.hide(); } + + if (typeof checkActiveFavoriteMatch === 'function') { + checkActiveFavoriteMatch(); + } }); @@ -361,6 +365,10 @@ document.getElementById('reset-filters').addEventListener('click', async functio // Update filter badge after URL change updateFilterBadge(); + if (typeof checkActiveFavoriteMatch === 'function') { + checkActiveFavoriteMatch(); + } + // Clear search input too $('#interventions-search-input').val(''); $('#interventions-search-clear').css('visibility', 'hidden'); @@ -2414,7 +2422,7 @@ function countActiveFilters() { 'provider', 'contract', 'assigned_team', 'assigned_member', 'assigned_controller', 'intervention_manager', 'inspector', 'pause_reason', 'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', 'locations', - 'overdue', 'to_be_planned_soon' + 'overdue', 'to_be_planned_soon', 'followed_only', 'is_blocked_due_to_contract' ]; filterParams.forEach(param => { @@ -2459,7 +2467,7 @@ function savePreferencesFromUrl() { 'provider', 'contract', 'assigned_team', 'assigned_member', 'assigned_controller', 'intervention_manager', 'inspector', 'pause_reason', 'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', 'locations', - 'overdue', 'to_be_planned_soon' + 'overdue', 'to_be_planned_soon', 'followed_only', 'is_blocked_due_to_contract' ]; filterParams.forEach(param => { @@ -2513,7 +2521,7 @@ async function savePreferencesFromForm(urlParams) { 'provider', 'contract', 'assigned_team', 'assigned_member', 'assigned_controller', 'intervention_manager', 'inspector', 'pause_reason', 'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', 'locations', - 'overdue', 'to_be_planned_soon' + 'overdue', 'to_be_planned_soon', 'followed_only', 'is_blocked_due_to_contract' ]; filterParams.forEach(param => { @@ -2566,6 +2574,7 @@ document.addEventListener('DOMContentLoaded', function() { updateFilterBadge(); initDateFilterPresets(); initRolePersonFilter(); + initFavoriteFilters(); // Save preferences when table sort changes (user action only) const $table = $('#interventions-table'); @@ -2579,6 +2588,7 @@ document.addEventListener('DOMContentLoaded', function() { // Update badge after page fully loads (including after filter redirect) window.addEventListener('load', function() { updateFilterBadge(); + checkActiveFavoriteMatch(); // Restore search input from URL if present const _urlParams = new URLSearchParams(window.location.search); @@ -3109,3 +3119,547 @@ function initDateFilterPresets() { updateDateFilterTimelinePreview(); } + +// ==================== Favorite Filters Management ==================== + +function initFavoriteFilters() { + renderFavoriteFiltersUI(); + checkActiveFavoriteMatch(); + + // Open save modal buttons + const btnOpenSave = document.getElementById('btn-open-save-favorite-modal'); + if (btnOpenSave) { + btnOpenSave.addEventListener('click', function(e) { + e.preventDefault(); + openSaveFavoriteModal(); + }); + } + + const btnModalSave = document.getElementById('modal-save-favorite-btn'); + if (btnModalSave) { + btnModalSave.addEventListener('click', function(e) { + e.preventDefault(); + openSaveFavoriteModal(); + }); + } + + // Modal favorite select change + const modalSelect = document.getElementById('modal-favorite-filter-select'); + if (modalSelect) { + modalSelect.addEventListener('change', function() { + if (this.value) { + applyFavoriteFilter(this.value); + } + }); + } + + // Confirm save button + const btnConfirmSave = document.getElementById('btn-confirm-save-favorite'); + if (btnConfirmSave) { + btnConfirmSave.addEventListener('click', function() { + confirmSaveFavorite(); + }); + } + + // Submit on Enter in save favorite modal + const saveNameInput = document.getElementById('save-favorite-name'); + if (saveNameInput) { + saveNameInput.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + e.preventDefault(); + confirmSaveFavorite(); + } + }); + } +} + +function renderFavoriteFiltersUI() { + const countBadge = document.getElementById('favorite-filters-count'); + const itemsContainer = document.getElementById('favorite-filters-items'); + const modalSelect = document.getElementById('modal-favorite-filter-select'); + + if (countBadge) { + countBadge.textContent = (typeof userFavoriteFilters !== 'undefined' && Array.isArray(userFavoriteFilters)) ? userFavoriteFilters.length : 0; + } + + if (itemsContainer) { + if (!userFavoriteFilters || !userFavoriteFilters.length) { + itemsContainer.innerHTML = ` +
  • + ${gettext('Aucun favori enregistré')} +
  • + `; + } else { + let html = ''; + userFavoriteFilters.forEach(fav => { + html += ` + + `; + }); + itemsContainer.innerHTML = html; + } + } + + if (modalSelect) { + const currentVal = modalSelect.value; + let selectHtml = ``; + if (typeof userFavoriteFilters !== 'undefined' && Array.isArray(userFavoriteFilters)) { + userFavoriteFilters.forEach(fav => { + selectHtml += ``; + }); + } + modalSelect.innerHTML = selectHtml; + modalSelect.value = currentVal; + } +} + +function getCurrentFilterValues() { + const form = document.getElementById('filter-form'); + const filters = {}; + if (!form) return filters; + const formData = new FormData(form); + + const multiFields = [ + 'thematic', 'status', 'priority', 'maintain_type', 'category', 'type', + 'provider', 'contract', 'assigned_team', 'assigned_member', 'assigned_control_team', + 'pause_reason' + ]; + + multiFields.forEach(field => { + const values = formData.getAll(field).filter(v => v.trim() !== ''); + if (values.length > 0) { + filters[field] = values; + } + }); + + const singleFields = [ + 'assigned_controller', 'intervention_manager', 'inspector', + 'begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max', + 'overdue', 'followed_only', 'to_be_planned_soon', 'is_blocked_due_to_contract' + ]; + + singleFields.forEach(field => { + const val = formData.get(field); + if (val && val.trim() !== '') { + filters[field] = val.trim(); + } + }); + + // Locations + if (typeof selectedLocations !== 'undefined' && selectedLocations.length > 0) { + filters['locations'] = selectedLocations.map(x => ({ ct: x.ct, obj: x.obj })); + } + + return filters; +} + +function areFilterObjectsEqual(f1, f2) { + if (!f1 || !f2) return false; + const keys1 = Object.keys(f1).filter(k => f1[k] !== undefined && f1[k] !== null && f1[k] !== '' && (!Array.isArray(f1[k]) || f1[k].length > 0)).sort(); + const keys2 = Object.keys(f2).filter(k => f2[k] !== undefined && f2[k] !== null && f2[k] !== '' && (!Array.isArray(f2[k]) || f2[k].length > 0)).sort(); + + if (keys1.length !== keys2.length) return false; + for (let i = 0; i < keys1.length; i++) { + const k = keys1[i]; + if (k !== keys2[i]) return false; + let v1 = f1[k]; + let v2 = f2[k]; + if (Array.isArray(v1) || Array.isArray(v2)) { + const arr1 = (Array.isArray(v1) ? v1 : [v1]).map(String).sort(); + const arr2 = (Array.isArray(v2) ? v2 : [v2]).map(String).sort(); + if (arr1.length !== arr2.length) return false; + for (let j = 0; j < arr1.length; j++) { + if (arr1[j] !== arr2[j]) return false; + } + } else if (k === 'locations') { + if (JSON.stringify(v1) !== JSON.stringify(v2)) return false; + } else { + if (String(v1) !== String(v2)) return false; + } + } + return true; +} + +function checkActiveFavoriteMatch() { + const currentFilters = getCurrentFilterValues(); + let matchedFav = null; + + if (typeof userFavoriteFilters !== 'undefined' && Array.isArray(userFavoriteFilters)) { + for (const fav of userFavoriteFilters) { + if (areFilterObjectsEqual(fav.filters, currentFilters)) { + matchedFav = fav; + break; + } + } + } + + const activeLabel = document.getElementById('active-favorite-label'); + const modalSelect = document.getElementById('modal-favorite-filter-select'); + + // Reset all star icons and active classes + document.querySelectorAll('.dropdown-item-favorite').forEach(el => el.classList.remove('active')); + document.querySelectorAll('.favorite-star-icon').forEach(icon => { + icon.className = 'bi bi-star favorite-star-icon text-muted'; + }); + + if (matchedFav) { + if (activeLabel) { + activeLabel.textContent = matchedFav.name; + } + const activeItem = document.getElementById(`fav-item-${matchedFav.id}`); + if (activeItem) { + activeItem.classList.add('active'); + } + const activeStar = document.getElementById(`fav-star-${matchedFav.id}`); + if (activeStar) { + activeStar.className = 'bi bi-star-fill favorite-star-icon text-warning'; + } + if (modalSelect) { + modalSelect.value = matchedFav.id; + } + } else { + if (activeLabel) { + activeLabel.textContent = gettext('Favoris'); + } + if (modalSelect) { + modalSelect.value = ''; + } + } +} + +function generateFilterSummary(filters) { + if (!filters || Object.keys(filters).length === 0) { + return `${gettext('Aucun filtre sélectionné (toutes les interventions).')}`; + } + const badges = []; + + if (filters.thematic && filters.thematic.length) { + const names = filters.thematic.map(code => { + const lbl = document.querySelector(`label[for="thematic_${code}"]`); + return lbl ? lbl.textContent.trim() : code; + }); + badges.push(`${gettext('Thématique(s)')} : ${names.join(', ')}`); + } + + if (filters.status && filters.status.length) { + const names = filters.status.map(s => { + const lbl = document.querySelector(`label[for="status_${s}"]`); + return lbl ? lbl.textContent.trim() : s; + }); + badges.push(`${gettext('Statut(s)')} : ${names.join(', ')}`); + } + + if (filters.priority && filters.priority.length) { + badges.push(`${gettext('Priorité')} : ${filters.priority.join(', ')}`); + } + + if (filters.maintain_type && filters.maintain_type.length) { + badges.push(`${gettext('Type maintenance')} : ${filters.maintain_type.join(', ')}`); + } + + if (filters.category && filters.category.length) { + badges.push(`${gettext('Catégories')} : ${filters.category.length} ${gettext('sélectionnée(s)')}`); + } + + if (filters.begin_date_min || filters.begin_date_max) { + badges.push(`${gettext('Date début')} : [${filters.begin_date_min || '—'} → ${filters.begin_date_max || '—'}]`); + } + if (filters.end_date_min || filters.end_date_max) { + badges.push(`${gettext('Date fin')} : [${filters.end_date_min || '—'} → ${filters.end_date_max || '—'}]`); + } + + if (filters.overdue === '1') { + badges.push(`${gettext('Non traitées (en retard)')}`); + } + if (filters.followed_only === '1') { + badges.push(`${gettext('Suivies uniquement')}`); + } + if (filters.is_blocked_due_to_contract === '1') { + badges.push(`${gettext('Marché bloqué : Oui')}`); + } else if (filters.is_blocked_due_to_contract === '0') { + badges.push(`${gettext('Marché bloqué : Non')}`); + } + + if (filters.locations && filters.locations.length) { + badges.push(`${gettext('Localisations')} : ${filters.locations.length}`); + } + + if (badges.length === 0) { + return `${gettext('Filtres divers appliqués.')}`; + } + + return badges.map(b => `
    ${b}
    `).join(''); +} + +function openSaveFavoriteModal(existingId = null) { + const currentFilters = getCurrentFilterValues(); + const modalEl = document.getElementById('saveFavoriteModal'); + if (!modalEl) return; + + const idInput = document.getElementById('save-favorite-id'); + const nameInput = document.getElementById('save-favorite-name'); + const summaryEl = document.getElementById('save-favorite-summary'); + + if (existingId) { + const existing = userFavoriteFilters.find(f => f.id === existingId); + if (existing) { + idInput.value = existing.id; + nameInput.value = existing.name; + } + } else { + idInput.value = ''; + nameInput.value = ''; + } + + if (summaryEl) { + summaryEl.innerHTML = generateFilterSummary(currentFilters); + } + + nameInput.classList.remove('is-invalid'); + const modal = bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + + setTimeout(() => { + nameInput.focus(); + }, 400); +} + +async function confirmSaveFavorite() { + const nameInput = document.getElementById('save-favorite-name'); + const idInput = document.getElementById('save-favorite-id'); + const name = nameInput.value.trim(); + if (!name) { + nameInput.classList.add('is-invalid'); + return; + } + nameInput.classList.remove('is-invalid'); + + const favoriteId = idInput.value || null; + const filters = getCurrentFilterValues(); + + try { + const response = await fetch(saveFavoriteUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + id: favoriteId, + name: name, + filters: filters + }) + }); + + const data = await response.json(); + if (data.success) { + userFavoriteFilters = data.favorites; + renderFavoriteFiltersUI(); + checkActiveFavoriteMatch(); + + const modalEl = document.getElementById('saveFavoriteModal'); + const modal = bootstrap.Modal.getInstance(modalEl); + if (modal) modal.hide(); + + showToast(gettext('Filtre favori enregistré avec succès.'), 'success'); + } else { + alert(data.error || gettext('Erreur lors de la sauvegarde du favori.')); + } + } catch (err) { + alert(gettext('Erreur de communication avec le serveur.')); + } +} + +async function deleteFavoriteFilter(favoriteId, event) { + if (event) event.stopPropagation(); + const fav = userFavoriteFilters.find(f => f.id === favoriteId); + if (!fav) return; + + const confirmMsg = gettext("Voulez-vous vraiment supprimer le favori '%s' ?").replace('%s', fav.name); + if (!confirm(confirmMsg)) return; + + try { + const response = await fetch(deleteFavoriteUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ id: favoriteId }) + }); + + const data = await response.json(); + if (data.success) { + userFavoriteFilters = data.favorites; + renderFavoriteFiltersUI(); + checkActiveFavoriteMatch(); + showToast(gettext('Filtre favori supprimé.'), 'info'); + } else { + alert(data.error || gettext('Erreur lors de la suppression du favori.')); + } + } catch (err) { + alert(gettext('Erreur de communication avec le serveur.')); + } +} + +async function updateFavoriteWithCurrentFilters(favoriteId, event) { + if (event) event.stopPropagation(); + const fav = userFavoriteFilters.find(f => f.id === favoriteId); + if (!fav) return; + + const confirmMsg = gettext("Mettre à jour le favori '%s' avec les filtres actuellement actifs ?").replace('%s', fav.name); + if (!confirm(confirmMsg)) return; + + const currentFilters = getCurrentFilterValues(); + + try { + const response = await fetch(saveFavoriteUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCookie('csrftoken') + }, + body: JSON.stringify({ + id: favoriteId, + name: fav.name, + filters: currentFilters + }) + }); + + const data = await response.json(); + if (data.success) { + userFavoriteFilters = data.favorites; + renderFavoriteFiltersUI(); + checkActiveFavoriteMatch(); + showToast(gettext("Favori '%s' mis à jour.").replace('%s', fav.name), 'success'); + } else { + alert(data.error || gettext('Erreur lors de la mise à jour du favori.')); + } + } catch (err) { + alert(gettext('Erreur de communication avec le serveur.')); + } +} + +async function applyFavoriteFilter(favoriteId) { + if (!favoriteId) return; + const fav = userFavoriteFilters.find(f => f.id === favoriteId); + if (!fav) return; + + const form = document.getElementById('filter-form'); + if (!form) return; + + // 1. Reset all fields first + form.reset(); + form.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + checkbox.checked = false; + }); + + document.getElementById('begin_date_min').value = ''; + document.getElementById('begin_date_max').value = ''; + document.getElementById('end_date_min').value = ''; + document.getElementById('end_date_max').value = ''; + + const blockedSelect = document.getElementById('is_blocked_due_to_contract'); + if (blockedSelect) blockedSelect.value = ''; + + selectedLocations = []; + + // 2. Restore values from fav.filters + const filters = fav.filters || {}; + for (const [key, value] of Object.entries(filters)) { + if (key === 'locations') { + try { + let locs = value; + if (typeof locs === 'string') { + locs = JSON.parse(locs); + } + if (Array.isArray(locs)) { + selectedLocations = locs; + } + } catch (e) { + selectedLocations = []; + } + } else if (['begin_date_min', 'begin_date_max', 'end_date_min', 'end_date_max'].includes(key)) { + const input = document.getElementById(key); + if (input) input.value = value; + } else if (key === 'is_blocked_due_to_contract') { + if (blockedSelect) blockedSelect.value = value; + } else { + // Checkboxes + const valList = Array.isArray(value) ? value.map(String) : [String(value)]; + form.querySelectorAll(`input[type="checkbox"][name="${key}"]`).forEach(cb => { + cb.checked = valList.includes(String(cb.value)); + }); + } + } + + renderLocationChips(); + togglePauseReasonFilter(); + filterCategoriesByThematics(); + filterProvidersAndContracts(); + updateDateFilterTimelinePreview(); + + // 3. Build URL params & save preferences + const formData = new FormData(form); + const params = new URLSearchParams(); + for (const [k, v] of formData.entries()) { + if (v.trim() !== '') params.append(k, v); + } + if (selectedLocations.length) { + const payload = selectedLocations.map(x => ({ ct: x.ct, obj: x.obj })); + params.set('locations', JSON.stringify(payload)); + } else { + params.delete('locations'); + } + + // Save as current active preferences + await savePreferencesFromForm(params); + + // Preserve search input if present + const _activeQ = $('#interventions-search-input').val().trim(); + const _activeMode = document.querySelector('input[name="search-mode"]:checked')?.value || 'and'; + if (_activeQ) { + params.set('q', _activeQ); + params.set('search_mode', _activeMode); + } + + // Update URL & filter badge + const newUrl = `${window.location.pathname}?${params.toString()}`; + window.history.pushState({}, '', newUrl); + updateFilterBadge(); + + // Reload table and map data + handleGeojsonMapLoading(false, interventionsgeojsonloader, 'interventions'); + interventionsgeojsonloader.loadGeojson(interventionsgeojsonUrl + '?' + params); + + // Update active label and stars + checkActiveFavoriteMatch(); + + // Close modal if open + const filterModalEl = document.getElementById('filterModal'); + if (filterModalEl) { + const filterModal = bootstrap.Modal.getInstance(filterModalEl); + if (filterModal) filterModal.hide(); + } + + showToast(gettext("Filtre '%s' appliqué.").replace('%s', fav.name), 'info'); +} + +// Make functions globally available for inline onclicks +window.applyFavoriteFilter = applyFavoriteFilter; +window.deleteFavoriteFilter = deleteFavoriteFilter; +window.updateFavoriteWithCurrentFilters = updateFavoriteWithCurrentFilters; +window.openSaveFavoriteModal = openSaveFavoriteModal; diff --git a/loko/interventions/templates/interventions/interventions_index.html b/loko/interventions/templates/interventions/interventions_index.html index 8ba11e9..812d3dc 100644 --- a/loko/interventions/templates/interventions/interventions_index.html +++ b/loko/interventions/templates/interventions/interventions_index.html @@ -45,6 +45,29 @@ margin-top: 2px; } + .dropdown-item-favorite { + transition: background-color 0.15s ease; + border-radius: 4px; + margin: 2px 4px; + } + .dropdown-item-favorite:hover { + background-color: #f1f5f9; + } + .dropdown-item-favorite.active { + background-color: #e7f1ff; + } + .dropdown-item-favorite.active .fav-name { + color: #0c5cc9; + font-weight: 600 !important; + } + .favorite-actions button { + opacity: 0.6; + transition: opacity 0.15s; + } + .dropdown-item-favorite:hover .favorite-actions button { + opacity: 1; + } + .popup-scrollable { max-height: 200px; overflow-y: auto; @@ -555,6 +578,20 @@ + + +
    @@ -1225,18 +1298,40 @@