diff --git a/docs/pr-1-rooms-filter.md b/docs/pr-1-rooms-filter.md new file mode 100644 index 000000000..836f4d6b4 --- /dev/null +++ b/docs/pr-1-rooms-filter.md @@ -0,0 +1,42 @@ +# PR 1 - Filtro de Habitaciones + +## Objetivo de negocio +Incorporar un mecanismo de búsqueda en la sección **Habitaciones** para mejorar la localización de cuartos por nombre y reducir tiempo operativo en front desk/soporte. + +## Alcance implementado +- Se agregó un formulario de filtro por nombre en la vista de Habitaciones. +- El filtro opera por coincidencia parcial (`icontains`) sobre `Room.name`. +- Se mantiene el comportamiento original cuando no hay criterio de búsqueda (listado completo). +- Se agregó estado vacío cuando no existen coincidencias. +- Se mejoró la presentación de los controles de búsqueda para una experiencia más clara y consistente. + +## Cambios funcionales y técnicos +1. **Backend** +- Se actualizó la lógica de `RoomsView` para aceptar parámetro `name` y filtrar resultados. +- Se agregó un formulario dedicado de filtro para desacoplar validación y render. + +2. **Frontend** +- Se incorporó barra de filtro con input, botón de búsqueda y acción de limpieza. +- Se añadió feedback contextual del total de resultados. +- Se mejoró el layout visual del bloque de controles para reforzar jerarquía y legibilidad. + +3. **Calidad** +- Se incorporaron pruebas automatizadas que validan: + - listado completo sin filtro, + - filtro por coincidencia parcial, + - estado sin resultados. + +## Archivos modificados +- `pms/forms.py` +- `pms/views.py` +- `pms/templates/rooms.html` +- `pms/statics/css/style.css` +- `pms/tests.py` + +## Pruebas ejecutadas +- `python manage.py test pms.tests.RoomsFilterTests` + +## Resultado esperado para el producto +- Menor fricción en operación diaria al buscar habitaciones. +- Mejor percepción de calidad UI en una pantalla de uso frecuente. +- Base funcional y visual estable para iteraciones futuras en catálogo/gestión de habitaciones. diff --git a/pms/forms.py b/pms/forms.py index f1bc68d08..9472f7de5 100644 --- a/pms/forms.py +++ b/pms/forms.py @@ -56,3 +56,17 @@ class Meta: 'total': forms.HiddenInput(), 'state': forms.HiddenInput(), } + + +class RoomFilterForm(forms.Form): + name = forms.CharField( + required=False, + label="Buscar habitación", + widget=forms.TextInput( + attrs={ + "placeholder": "Ej: Room 1", + "class": "form-control rooms-filter-input", + "autocomplete": "off", + } + ), + ) diff --git a/pms/statics/css/style.css b/pms/statics/css/style.css index 91d9999a8..07ad88f3a 100644 --- a/pms/statics/css/style.css +++ b/pms/statics/css/style.css @@ -38,4 +38,172 @@ body{ justify-content: center; height: 100%; align-items: center; -} \ No newline at end of file +} + +.rooms-page{ + --rooms-surface: #ffffff; + --rooms-muted: #6c757d; + --rooms-line: #d9e1e7; + --rooms-brand: #0f766e; + --rooms-brand-soft: #e6f4f3; +} + +.rooms-hero{ + background: linear-gradient(135deg, #eff6ff 0%, #f0fdf4 100%); + border: 1px solid #d9e1e7; + border-radius: 16px; + padding: 1.5rem; + margin-bottom: 1rem; +} + +.rooms-title{ + margin: 0; + font-size: 1.8rem; + font-weight: 700; + letter-spacing: -0.02em; +} + +.rooms-subtitle{ + margin: 0.4rem 0 0; + color: var(--rooms-muted); +} + +.rooms-toolbar{ + background: var(--rooms-surface); + border-radius: 16px; + padding: 1rem; + margin-bottom: 1.2rem; +} + +.rooms-controls-panel{ + border: 1px solid var(--rooms-line); +} + +.rooms-filter-form{ + display: flex; + align-items: flex-end; + gap: 0.6rem; + flex-wrap: wrap; +} + +.rooms-filter-field{ + flex: 1 1 360px; + min-width: 240px; +} + +.rooms-filter-label{ + display: inline-block; + margin-bottom: 0.35rem; + color: var(--rooms-muted); + font-size: 0.9rem; + font-weight: 600; +} + +.rooms-filter-input-wrap{ + position: relative; +} + +.rooms-filter-icon{ + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + color: var(--rooms-muted); +} + +.rooms-filter-input{ + border-radius: 999px; + border: 1px solid var(--rooms-line); + padding-left: 2.2rem; + height: 44px; +} + +.rooms-filter-input:focus{ + border-color: var(--rooms-brand); + box-shadow: 0 0 0 0.2rem rgba(15, 118, 110, 0.15); +} + +.rooms-filter-btn{ + border-radius: 999px; + background: var(--rooms-brand); + border-color: var(--rooms-brand); + min-width: 100px; + height: 44px; +} + +.rooms-filter-btn:hover{ + background: #0a5e57; + border-color: #0a5e57; +} + +.rooms-filter-clear{ + border-radius: 999px; + height: 44px; +} + +.rooms-results-meta{ + margin-top: 0.75rem; + color: var(--rooms-muted); + font-size: 0.95rem; +} + +.rooms-results-chip{ + display: inline-flex; + border: 1px solid var(--rooms-line); + border-radius: 999px; + padding: 0.35rem 0.8rem; + background: rgba(15, 118, 110, 0.05); +} + +.rooms-grid{ + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); +} + +.rooms-card{ + background: var(--rooms-surface); + border: 1px solid var(--rooms-line); + border-radius: 14px; + padding: 1rem; + transition: transform 180ms ease, box-shadow 180ms ease; +} + +.rooms-card:hover{ + transform: translateY(-2px); + box-shadow: 0 10px 24px rgba(16, 24, 40, 0.08); +} + +.rooms-card-head{ + display: flex; + justify-content: space-between; + align-items: center; + gap: 0.8rem; + margin-bottom: 0.9rem; +} + +.rooms-card-title{ + margin: 0; + font-size: 1.05rem; + font-weight: 700; +} + +.rooms-type-chip{ + background: var(--rooms-brand-soft); + color: var(--rooms-brand); + border-radius: 999px; + font-size: 0.82rem; + font-weight: 600; + padding: 0.25rem 0.65rem; + white-space: nowrap; +} + +.rooms-card-actions{ + display: flex; + justify-content: flex-start; +} + +.rooms-empty-state{ + border-radius: 14px; + border: 1px solid #ffe58f; +} diff --git a/pms/templates/rooms.html b/pms/templates/rooms.html index c30929f1f..a17f83393 100644 --- a/pms/templates/rooms.html +++ b/pms/templates/rooms.html @@ -1,19 +1,53 @@ {% extends "main.html"%} {% block content %} -

Habitaciones del hotel

-{% for room in rooms%} -
-
-
- {{room.name}} ({{room.room_type__name}}) -
-
- Ver detalles +
+
+

Habitaciones del hotel

+

Filtra por nombre y encuentra la habitación en segundos.

+
+ +
+
+
+ +
+ + {{ room_filter_form.name }} +
+
+ + Limpiar +
+
+ {% if room_name_filter %} + Resultados para "{{ room_name_filter }}": {{ results_count }} + {% else %} + Total habitaciones: {{ results_count }} + {% endif %}
-
- -
-{% endfor %} + + {% if rooms %} +
+ {% for room in rooms %} + + {% endfor %} +
+ {% else %} + + {% endif %} + {% endblock content%} diff --git a/pms/tests.py b/pms/tests.py index 7ce503c2d..98cedd235 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,3 +1,36 @@ from django.test import TestCase +from django.test.utils import override_settings +from django.urls import reverse -# Create your tests here. +from .models import Room, Room_type + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class RoomsFilterTests(TestCase): + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) + Room.objects.create(room_type=room_type, name="Room 1.1", description="Desc") + Room.objects.create(room_type=room_type, name="Room 1.2", description="Desc") + Room.objects.create(room_type=room_type, name="Room 2.1", description="Desc") + + def test_rooms_page_without_filter_displays_all_rooms(self): + response = self.client.get(reverse("rooms")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Room 1.1") + self.assertContains(response, "Room 1.2") + self.assertContains(response, "Room 2.1") + self.assertContains(response, "Total habitaciones: 3") + + def test_rooms_page_filters_by_partial_name(self): + response = self.client.get(reverse("rooms"), {"name": "Room 1"}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Resultados para "Room 1": 2', html=True) + self.assertContains(response, "Room 1.1") + self.assertContains(response, "Room 1.2") + self.assertNotContains(response, "Room 2.1") + + def test_rooms_page_shows_empty_state_when_no_matches(self): + response = self.client.get(reverse("rooms"), {"name": "Suite"}) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No se encontraron habitaciones para ese criterio.") diff --git a/pms/views.py b/pms/views.py index f38563933..c4f7d3805 100644 --- a/pms/views.py +++ b/pms/views.py @@ -238,9 +238,20 @@ def get(self, request, pk): class RoomsView(View): def get(self, request): - # renders a list of rooms - rooms = Room.objects.all().values("name", "room_type__name", "id") + room_filter_form = RoomFilterForm(request.GET or None) + room_name = "" + rooms = Room.objects.all() + if room_filter_form.is_valid(): + room_name = room_filter_form.cleaned_data.get("name", "").strip() + if room_name: + rooms = rooms.filter(name__icontains=room_name) + + results_count = rooms.count() + rooms = rooms.values("name", "room_type__name", "id") context = { - 'rooms': rooms + 'rooms': rooms, + 'room_filter_form': room_filter_form, + 'room_name_filter': room_name, + 'results_count': results_count, } return render(request, "rooms.html", context)