From 19879adb63c863f81fcd8868a4bac3bd973838bd Mon Sep 17 00:00:00 2001 From: ArielHS Date: Mon, 2 Mar 2026 22:11:17 +0000 Subject: [PATCH 1/7] feat(rooms): implementar filtro de habitaciones con UX moderna y cobertura de pruebas Contexto de negocio: - Se implementa el requerimiento PR 1 del challenge: filtrar la seccion Habitaciones por coincidencia parcial del campo name. Alcance funcional entregado: - Se agrega RoomFilterForm para encapsular validacion y mantener la vista desacoplada de la capa de presentacion. - RoomsView incorpora lectura de query params y filtro name__icontains, preservando el listado completo cuando no hay criterio. - Se agrega metadata de resultados para mejorar feedback al usuario (conteo total y conteo filtrado). Mejoras de interfaz (orientadas a usabilidad): - Nueva cabecera visual en Habitaciones con mensaje de accion claro. - Barra de busqueda prominente con icono, CTA principal y accion de limpiar filtro. - Grid responsivo de tarjetas para lectura rapida en desktop y mobile. - Estado vacio explicito para evitar ambiguedad cuando no hay coincidencias. Calidad y verificacion: - Se incorporan tests automatizados de vista para: sin filtro, filtro parcial y sin resultados. - Validado con python manage.py test pms.tests.RoomsFilterTests. --- pms/forms.py | 14 ++++ pms/statics/css/style.css | 147 +++++++++++++++++++++++++++++++++++++- pms/templates/rooms.html | 57 +++++++++++---- pms/tests.py | 35 ++++++++- pms/views.py | 17 ++++- 5 files changed, 252 insertions(+), 18 deletions(-) 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..97716c2fb 100644 --- a/pms/statics/css/style.css +++ b/pms/statics/css/style.css @@ -38,4 +38,149 @@ 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-filter-form{ + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.rooms-filter-input-wrap{ + position: relative; + flex: 1 1 320px; + min-width: 220px; +} + +.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-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..e38d03212 100644 --- a/pms/templates/rooms.html +++ b/pms/templates/rooms.html @@ -1,19 +1,50 @@ {% 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) From caeef70eb1a514dc729171cebf5a692d09743699 Mon Sep 17 00:00:00 2001 From: ArielHS Date: Mon, 2 Mar 2026 22:28:58 +0000 Subject: [PATCH 2/7] =?UTF-8?q?feat(dashboard):=20incorporar=20widget=20de?= =?UTF-8?q?=20ocupacion=20con=20redise=C3=B1o=20consistente=20y=20pruebas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Contexto de negocio: - Se implementa el requerimiento PR 2 del challenge: agregar el KPI % ocupacion en Dashboard. - El objetivo es exponer una metrica operativa accionable sin romper la experiencia visual existente. Implementacion funcional: - DashboardView ahora calcula occupancy_percentage usando la formula: reservas confirmadas (state=NEW) / total de habitaciones * 100. - Se contempla el caso borde sin habitaciones para evitar division por cero, devolviendo 0. Mejoras de interfaz (reutilizando el diseño actual): - Se mantiene el lenguaje visual del proyecto y se refuerza con un layout en grid responsivo para widgets. - Se agrega una nueva tarjeta para % ocupacion alineada al resto de indicadores. - Se mejora legibilidad con jerarquia visual consistente, colores diferenciados por KPI y estructura adaptativa para desktop/mobile. Calidad y verificacion: - Se añadieron tests para validar el KPI en escenario normal y sin habitaciones. - Suite ejecutada: python manage.py test pms.tests.RoomsFilterTests pms.tests.DashboardOccupancyTests pms.tests.DashboardOccupancyWithoutRoomsTests. - Resultado: OK (5 tests). --- pms/statics/css/style.css | 34 +++++++++++++++++++++++++ pms/templates/dashboard.html | 36 ++++++++++++++++----------- pms/tests.py | 48 +++++++++++++++++++++++++++++++++++- pms/views.py | 11 ++++++++- 4 files changed, 112 insertions(+), 17 deletions(-) diff --git a/pms/statics/css/style.css b/pms/statics/css/style.css index 97716c2fb..dca23b8a8 100644 --- a/pms/statics/css/style.css +++ b/pms/statics/css/style.css @@ -184,3 +184,37 @@ body{ border-radius: 14px; border: 1px solid #ffe58f; } + +.dashboard-grid{ + display: grid; + gap: 0.9rem; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); +} + +.dashboard-card{ + width: 100%; + min-height: 180px; + border: 0; + border-radius: 14px; + box-shadow: 0 10px 24px rgba(16, 24, 40, 0.08); +} + +.dashboard-card-blue{ + background-color: #2d5be3; +} + +.dashboard-card-green{ + background-color: #00ab74; +} + +.dashboard-card-amber{ + background-color: #eeb258; +} + +.dashboard-card-coral{ + background-color: #ff7f7f; +} + +.dashboard-card-teal{ + background-color: #0f766e; +} diff --git a/pms/templates/dashboard.html b/pms/templates/dashboard.html index 10f0285cc..30b3f33af 100644 --- a/pms/templates/dashboard.html +++ b/pms/templates/dashboard.html @@ -1,27 +1,33 @@ {% extends "main.html"%} {% block content %} -

Dashboard

-
-
Hoy
-
-
+
+
+

Dashboard

+

Resumen operativo del día y nivel de ocupación actual.

+
+ +
+
Reservas hechas
-

{{dashboard.new_bookings}}

+

{{ dashboard.new_bookings }}

-
+
Huéspedes ingresando
-

{{dashboard.incoming_guests}}

+

{{ dashboard.incoming_guests }}

-
+
Huéspedes saliendo
-

{{dashboard.outcoming_guests}}

+

{{ dashboard.outcoming_guests }}

- -
+
Total facturado
-

€ {% if dashboard.invoiced.total__sum == None %}0.00{% endif %} {{dashboard.invoiced.total__sum|floatformat:2}}

+

€ {% if dashboard.invoiced.total__sum == None %}0.00{% endif %} {{ dashboard.invoiced.total__sum|floatformat:2 }}

+
+
+
% ocupación
+

{{ dashboard.occupancy_percentage|floatformat:2 }}%

-
-{% endblock content%} \ No newline at end of file +
+{% endblock content%} diff --git a/pms/tests.py b/pms/tests.py index 98cedd235..ac445818a 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,8 +1,9 @@ from django.test import TestCase from django.test.utils import override_settings from django.urls import reverse +from datetime import date, timedelta -from .models import Room, Room_type +from .models import Booking, Room, Room_type @override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") @@ -34,3 +35,48 @@ 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.") + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class DashboardOccupancyTests(TestCase): + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) + room_1 = Room.objects.create(room_type=room_type, name="Room 1.1", description="Desc") + room_2 = Room.objects.create(room_type=room_type, name="Room 1.2", description="Desc") + today = date.today() + + Booking.objects.create( + state="NEW", + checkin=today, + checkout=today + timedelta(days=1), + room=room_1, + guests=1, + total=20, + code="CONF0001", + ) + Booking.objects.create( + state="DEL", + checkin=today, + checkout=today + timedelta(days=1), + room=room_2, + guests=1, + total=20, + code="CANC0001", + ) + + def test_dashboard_displays_occupancy_percentage_widget(self): + response = self.client.get(reverse("dashboard")) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "% ocupación") + self.assertContains(response, "50.00%") + self.assertEqual(response.context["dashboard"]["occupancy_percentage"], 50.0) + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class DashboardOccupancyWithoutRoomsTests(TestCase): + def test_dashboard_occupancy_is_zero_when_no_rooms_exist(self): + response = self.client.get(reverse("dashboard")) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.context["dashboard"]["occupancy_percentage"], 0) + self.assertContains(response, "0.00%") diff --git a/pms/views.py b/pms/views.py index c4f7d3805..df8dfe744 100644 --- a/pms/views.py +++ b/pms/views.py @@ -208,13 +208,22 @@ def get(self, request): .exclude(state="DEL") .aggregate(Sum('total')) ) + confirmed_bookings = (Booking.objects + .filter(state="NEW") + .values("id") + ).count() + total_rooms = Room.objects.values("id").count() + occupancy_percentage = 0 + if total_rooms > 0: + occupancy_percentage = (confirmed_bookings / total_rooms) * 100 # preparing context data dashboard = { 'new_bookings': new_bookings, 'incoming_guests': incoming, 'outcoming_guests': outcoming, - 'invoiced': invoiced + 'invoiced': invoiced, + 'occupancy_percentage': occupancy_percentage, } From f02fa3a3577d0d9a9d6f966be987db37ccb3bf7e Mon Sep 17 00:00:00 2001 From: ArielHS Date: Mon, 2 Mar 2026 23:05:37 +0000 Subject: [PATCH 3/7] feat(bookings): habilitar edicion de fechas con validacion de disponibilidad Contexto de negocio: - Se implementa el requerimiento PR 3 del challenge: permitir editar fechas desde el listado principal de reservas. - La solucion conserva la habitacion asignada y valida disponibilidad real antes de persistir cambios. Alcance funcional entregado: - Nueva ruta y flujo de edicion de fechas: booking//edit-dates. - Nuevo enlace en Home por reserva para acceder a la pantalla de edicion. - Nuevo formulario dedicado con campos checkin/checkout y guardado. - Validaciones server-side: rango cronologico valido, limite maximo 31/12/2026 y control de solapamientos en la misma habitacion. - Mensaje funcional requerido por consigna cuando no hay cupo: No hay disponibilidad para las fechas seleccionadas. - Recalculo automatico del total al actualizar fechas. Correcciones de UX/consistencia tecnica: - Los inputs de fecha se renderizan en formato ISO (YYYY-MM-DD), evitando desalineacion entre valor del textbox y calendario nativo. Calidad y verificacion: - Se incorporan pruebas automatizadas para: guardado exitoso, render ISO de fechas, bloqueo por solape y validacion de rango. - Suite ejecutada: python manage.py test pms.tests.EditBookingDatesTests. - Resultado: OK (4 tests). --- pms/forms.py | 45 ++++++++++++++++- pms/templates/edit_booking_dates.html | 38 +++++++++++++++ pms/templates/home.html | 4 +- pms/tests.py | 70 +++++++++++++++++++++++++++ pms/urls.py | 1 + pms/views.py | 45 +++++++++++++++++ 6 files changed, 200 insertions(+), 3 deletions(-) create mode 100644 pms/templates/edit_booking_dates.html diff --git a/pms/forms.py b/pms/forms.py index 9472f7de5..b4c3946c6 100644 --- a/pms/forms.py +++ b/pms/forms.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import date, datetime from django import forms from django.forms import ModelForm @@ -70,3 +70,46 @@ class RoomFilterForm(forms.Form): } ), ) + + +class BookingDatesEditForm(forms.Form): + checkin = forms.DateField( + label="Fecha de entrada", + input_formats=["%Y-%m-%d"], + widget=forms.DateInput( + format="%Y-%m-%d", + attrs={ + "type": "date", + "max": date(2026, 12, 31).strftime("%Y-%m-%d"), + "class": "form-control", + } + ), + ) + checkout = forms.DateField( + label="Fecha de salida", + input_formats=["%Y-%m-%d"], + widget=forms.DateInput( + format="%Y-%m-%d", + attrs={ + "type": "date", + "max": date(2026, 12, 31).strftime("%Y-%m-%d"), + "class": "form-control", + } + ), + ) + + def clean(self): + cleaned_data = super().clean() + checkin = cleaned_data.get("checkin") + checkout = cleaned_data.get("checkout") + if not checkin or not checkout: + return cleaned_data + + if checkout <= checkin: + raise forms.ValidationError("La fecha de salida debe ser posterior a la fecha de entrada") + + max_allowed_date = date(2026, 12, 31) + if checkin > max_allowed_date or checkout > max_allowed_date: + raise forms.ValidationError("Solo se permiten reservas hasta el 31/12/2026") + + return cleaned_data diff --git a/pms/templates/edit_booking_dates.html b/pms/templates/edit_booking_dates.html new file mode 100644 index 000000000..18cdd00c8 --- /dev/null +++ b/pms/templates/edit_booking_dates.html @@ -0,0 +1,38 @@ +{% extends "main.html"%} + +{% block content %} +
+
+

Editar fechas de reserva

+

Reserva {{ booking.code }} - {{ booking.room.name }}

+
+ +
+ {% if form.non_field_errors %} +
+ {% for error in form.non_field_errors %} +
{{ error }}
+ {% endfor %} +
+ {% endif %} + +
+ {% csrf_token %} + {% for field in form %} +
+ + {{ field }} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} + +
+ Volver + +
+
+
+
+{% endblock content %} diff --git a/pms/templates/home.html b/pms/templates/home.html index 1e61b8024..e57e55ac0 100644 --- a/pms/templates/home.html +++ b/pms/templates/home.html @@ -68,7 +68,7 @@

Reservas Realizadas

Editar datos de contacto
@@ -84,4 +84,4 @@

Reservas Realizadas

-{% endblock content%} \ No newline at end of file +{% endblock content%} diff --git a/pms/tests.py b/pms/tests.py index ac445818a..5ae92b3d7 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -80,3 +80,73 @@ def test_dashboard_occupancy_is_zero_when_no_rooms_exist(self): self.assertEqual(response.status_code, 200) self.assertEqual(response.context["dashboard"]["occupancy_percentage"], 0) self.assertContains(response, "0.00%") + + +@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") +class EditBookingDatesTests(TestCase): + @classmethod + def setUpTestData(cls): + room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) + cls.room = Room.objects.create(room_type=room_type, name="Room 1.1", description="Desc") + cls.booking = Booking.objects.create( + state="NEW", + checkin=date.today() + timedelta(days=5), + checkout=date.today() + timedelta(days=7), + room=cls.room, + guests=1, + total=40, + code="EDIT0001", + ) + + def test_edit_dates_view_updates_booking_when_room_is_available(self): + response = self.client.post( + reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), + data={ + "checkin": date.today() + timedelta(days=10), + "checkout": date.today() + timedelta(days=12), + }, + ) + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, "/") + + self.booking.refresh_from_db() + self.assertEqual(self.booking.checkin, date.today() + timedelta(days=10)) + self.assertEqual(self.booking.checkout, date.today() + timedelta(days=12)) + self.assertEqual(self.booking.total, 40) + + def test_edit_dates_view_renders_iso_values_for_date_inputs(self): + response = self.client.get(reverse("edit_booking_dates", kwargs={"pk": self.booking.id})) + self.assertEqual(response.status_code, 200) + self.assertContains(response, f'value="{self.booking.checkin.strftime("%Y-%m-%d")}"') + self.assertContains(response, f'value="{self.booking.checkout.strftime("%Y-%m-%d")}"') + + def test_edit_dates_view_displays_availability_error_when_dates_overlap(self): + Booking.objects.create( + state="NEW", + checkin=date.today() + timedelta(days=8), + checkout=date.today() + timedelta(days=11), + room=self.room, + guests=1, + total=60, + code="OVLP0001", + ) + response = self.client.post( + reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), + data={ + "checkin": date.today() + timedelta(days=9), + "checkout": date.today() + timedelta(days=10), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "No hay disponibilidad para las fechas seleccionadas") + + def test_edit_dates_view_displays_range_error_for_dates_after_2026_12_31(self): + response = self.client.post( + reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), + data={ + "checkin": date(2026, 12, 30), + "checkout": date(2027, 1, 2), + }, + ) + self.assertEqual(response.status_code, 200) + self.assertContains(response, "Solo se permiten reservas hasta el 31/12/2026") diff --git a/pms/urls.py b/pms/urls.py index c18714abf..2acbd0a22 100644 --- a/pms/urls.py +++ b/pms/urls.py @@ -8,6 +8,7 @@ path("search/booking/", views.BookingSearchView.as_view(), name="booking_search"), path("booking//", views.BookingView.as_view(), name="booking"), path("booking//edit", views.EditBookingView.as_view(), name="edit_booking"), + path("booking//edit-dates", views.EditBookingDatesView.as_view(), name="edit_booking_dates"), path("booking//delete", views.DeleteBookingView.as_view(), name="delete_booking"), path("rooms/", views.RoomsView.as_view(), name="rooms"), path("room//", views.RoomDetailsView.as_view(), name="room_details"), diff --git a/pms/views.py b/pms/views.py index df8dfe744..cdfcddfda 100644 --- a/pms/views.py +++ b/pms/views.py @@ -174,6 +174,51 @@ def post(self, request, pk): return redirect("/") +class EditBookingDatesView(View): + def get(self, request, pk): + booking = Booking.objects.get(id=pk) + form = BookingDatesEditForm( + initial={ + "checkin": booking.checkin, + "checkout": booking.checkout, + } + ) + context = { + "booking": booking, + "form": form, + } + return render(request, "edit_booking_dates.html", context) + + def post(self, request, pk): + booking = Booking.objects.get(id=pk) + form = BookingDatesEditForm(request.POST) + if form.is_valid(): + checkin = form.cleaned_data["checkin"] + checkout = form.cleaned_data["checkout"] + + has_overlap = (Booking.objects + .filter(room=booking.room, state="NEW") + .exclude(id=booking.id) + .filter(checkin__lte=checkout, checkout__gte=checkin) + .exists()) + + if has_overlap: + form.add_error(None, "No hay disponibilidad para las fechas seleccionadas") + else: + total_days = (checkout - checkin).days + booking.checkin = checkin + booking.checkout = checkout + booking.total = total_days * booking.room.room_type.price + booking.save(update_fields=["checkin", "checkout", "total"]) + return redirect("/") + + context = { + "booking": booking, + "form": form, + } + return render(request, "edit_booking_dates.html", context) + + class DashboardView(View): def get(self, request): from datetime import date, time, datetime From 99f7ad3104b2864a66a8578c8beb34f9eaacb80c Mon Sep 17 00:00:00 2001 From: ArielHS Date: Mon, 2 Mar 2026 23:08:28 +0000 Subject: [PATCH 4/7] =?UTF-8?q?style(ui):=20unificar=20dise=C3=B1o=20moder?= =?UTF-8?q?no=20en=20formularios=20de=20reserva=20y=20contacto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Aplica layout visual consistente en Nueva reserva y Editar datos de contacto - Mejora jerarquía de campos, botones y feedback visual - Extiende estilos compartidos en style.css para mantener coherencia de interfaz --- pms/statics/css/style.css | 59 ++++++++++++++++++++++++++ pms/templates/booking_search_form.html | 43 +++++++++++-------- pms/templates/edit_booking.html | 39 ++++++++++------- 3 files changed, 108 insertions(+), 33 deletions(-) diff --git a/pms/statics/css/style.css b/pms/statics/css/style.css index dca23b8a8..218ee59cb 100644 --- a/pms/statics/css/style.css +++ b/pms/statics/css/style.css @@ -218,3 +218,62 @@ body{ .dashboard-card-teal{ background-color: #0f766e; } + +.contact-edit-card input, +.contact-edit-card select, +.contact-edit-card textarea{ + border-radius: 10px; + border: 1px solid #d9e1e7; + min-height: 42px; + width: 100%; + padding: 0.5rem 0.75rem; +} + +.contact-edit-card input:focus, +.contact-edit-card select:focus, +.contact-edit-card textarea:focus{ + border-color: #0f766e; + box-shadow: 0 0 0 0.2rem rgba(15, 118, 110, 0.15); + outline: none; +} + +.booking-create-card input, +.booking-create-card select, +.booking-create-card textarea{ + border-radius: 10px; + border: 1px solid #d9e1e7; + min-height: 42px; + width: 100%; + padding: 0.5rem 0.75rem; +} + +.booking-create-card input:focus, +.booking-create-card select:focus, +.booking-create-card textarea:focus{ + border-color: #0f766e; + box-shadow: 0 0 0 0.2rem rgba(15, 118, 110, 0.15); + outline: none; +} + +.booking-days-panel{ + display: inline-flex; + align-items: center; + gap: 0.6rem; + background: #f4f9f8; + border: 1px solid #d9e1e7; + border-radius: 999px; + padding: 0.35rem 0.9rem; +} + +.booking-days-label{ + color: #6c757d; + font-weight: 500; +} + +.booking-days-value{ + color: #0f766e; + font-size: 1.1rem; + font-weight: 700; + min-width: 1.4rem; + text-align: center; +} diff --git a/pms/templates/booking_search_form.html b/pms/templates/booking_search_form.html index ed00bdbfc..b718fc698 100644 --- a/pms/templates/booking_search_form.html +++ b/pms/templates/booking_search_form.html @@ -1,24 +1,33 @@ {% extends "main.html"%} {% block content %} -

Nueva reserva

-
- {% csrf_token%} - {% for field in form %} -
-
{{field.label_tag}}
-
{{field}}
+
+
+

Nueva reserva

+

Selecciona fechas y cantidad de huéspedes para ver disponibilidad.

- {% endfor %} -
-
Días:
-
-
-
- -
+ +
+ + {% csrf_token%} + {% for field in form %} +
+ + {{ field }} +
+ {% endfor %} + +
+ Días de estancia + 0 +
+ +
+ +
+
- +
{% load static %} -{% endblock content%} \ No newline at end of file +{% endblock content%} diff --git a/pms/templates/edit_booking.html b/pms/templates/edit_booking.html index 5c591f356..bdf89b393 100644 --- a/pms/templates/edit_booking.html +++ b/pms/templates/edit_booking.html @@ -1,23 +1,30 @@ {% extends "main.html"%} {% block content %} -

Editar datos de contacto

-
- - {% csrf_token%} - {% for field in customer_form %} -
-
- {{field.label_tag}} -
-
- {{field}} -
+
+
+

Editar datos de contacto

+

Actualiza la información del cliente asociada a la reserva.

- {% endfor %} - Volver - - +
+
+ {% csrf_token%} + {% for field in customer_form %} +
+ + {{ field }} + {% for error in field.errors %} +
{{ error }}
+ {% endfor %} +
+ {% endfor %} +
+ Volver + +
+
+
+
{% endblock content%} From 4dd088adb5668cf3e53faa7b24f0459dbe9ba71e Mon Sep 17 00:00:00 2001 From: ArielHS Date: Mon, 2 Mar 2026 23:44:00 +0000 Subject: [PATCH 5/7] feat(ui): introducir sistema visual dual con tema persistente y coherencia de interfaz MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resumen ejecutivo: - Se implementa un sistema de tema claro/oscuro con persistencia en cookie para mejorar continuidad de uso entre sesiones. - La interfaz se alinea a una paleta más consistente y legible, con foco en experiencia SaaS/corporativa y uso intensivo en dashboards. Mejoras entregadas: - Toggle de tema integrado en la toolbar superior, ubicado a la derecha del botón Buscar, con iconografía contextual (sol/luna). - Inicialización temprana del tema en head para evitar parpadeo visual (FOUC) al cargar la página. - Persistencia client-side mediante cookie (theme_mode) con alcance global y expiración anual. - Definición de variables de diseño por modo (fondos, texto, bordes, superficies, acentos) para mantener consistencia transversal. - Conservación explícita de elementos críticos con identidad estable en ambos modos: * Colores originales de la toolbar y sus controles. * Colores originales de labels de estado Confirmada/Cancelada. * Colores fijos de widgets del Dashboard en ambos temas. Impacto esperado: - Mayor confort visual en contextos diurnos/nocturnos sin sacrificar reconocimiento de marca ni jerarquía funcional. - Menor fricción cognitiva por consistencia cromática en componentes clave y estados operativos. - Base de theming escalable para futuras vistas sin duplicar estilos. --- pms/statics/css/style.css | 216 ++++++++++++++++++++++++++++++----- pms/statics/js/theme_mode.js | 43 +++++++ pms/templates/main.html | 23 +++- 3 files changed, 250 insertions(+), 32 deletions(-) create mode 100644 pms/statics/js/theme_mode.js diff --git a/pms/statics/css/style.css b/pms/statics/css/style.css index 218ee59cb..d0f3cec64 100644 --- a/pms/statics/css/style.css +++ b/pms/statics/css/style.css @@ -1,5 +1,52 @@ +:root{ + --app-bg: #cecac4; + --app-bg-secondary: #c3bdb4; + --app-text: #3e3a35; + --app-muted: #6a635a; + --app-text-soft: #948b80; + --app-surface: #fffdf9; + --app-border: #e8e1d6; + --app-divider: #ddd3c4; + --app-hover: #f5f1ea; + --app-navbar-bg: #fffdf9; + --app-navbar-text: #3e3a35; + --app-primary: #b89c7a; + --app-primary-hover: #a98b67; + --app-primary-active: #8f7456; + --app-primary-soft: #f3ece2; + --app-secondary: #6a635a; + --app-accent: #8f7456; + --state-success-soft: #dce8d8; + --state-warning-soft: #f3e6d2; + --state-error-soft: #eed9d2; +} + +[data-theme="dark"]{ + --app-bg: #12161f; + --app-text: #e2e8f0; + --app-muted: #a7b2c1; + --app-surface: #1e2532; + --app-border: #303a4b; + --app-hover: #2b3546; + --app-navbar-bg: #020617; + --app-navbar-text: #e5e7eb; + --app-primary: #3aa7d8; + --app-primary-soft: #173a4d; + --app-secondary: #64748b; + --app-accent: #f59e0b; +} + +[data-theme="dark"] body{ + background: var(--app-bg); +} + body{ - padding-top: 4em + padding-top: 4em; + background: + radial-gradient(1100px 460px at 10% -8%, rgba(184, 156, 122, 0.10), transparent 52%), + radial-gradient(820px 380px at 92% -4%, rgba(143, 116, 86, 0.10), transparent 52%), + linear-gradient(180deg, #d7d2cb 0%, var(--app-bg) 48%, var(--app-bg-secondary) 100%); + color: var(--app-text); } @@ -11,7 +58,7 @@ body{ transition: background-color 250ms linear; } .hover-card:hover{ - background: #f0f0f0; + background: var(--app-hover); } .tag{ @@ -20,13 +67,12 @@ body{ border-radius: 0.3em; } .tag-red{ - - background: #ff7a7a; - color: white; + background: #ff7a7a !important; + color: #ffffff !important; } .tag-green { - background: #01a901; - color: white; + background: #01a901 !important; + color: #ffffff !important; } .card-customization{ width: 250px; @@ -41,19 +87,22 @@ body{ } .rooms-page{ - --rooms-surface: #ffffff; - --rooms-muted: #6c757d; - --rooms-line: #d9e1e7; - --rooms-brand: #0f766e; - --rooms-brand-soft: #e6f4f3; + --rooms-surface: var(--app-surface); + --rooms-muted: var(--app-muted); + --rooms-line: var(--app-border); + --rooms-brand: var(--app-primary); + --rooms-brand-soft: var(--app-primary-soft); } .rooms-hero{ - background: linear-gradient(135deg, #eff6ff 0%, #f0fdf4 100%); - border: 1px solid #d9e1e7; + background: linear-gradient(135deg, #fffdf9 0%, #f5f1ea 100%); + border: 1px solid var(--app-border); border-radius: 16px; padding: 1.5rem; margin-bottom: 1rem; + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.75), + 0 12px 30px rgba(15, 23, 42, 0.07); } .rooms-title{ @@ -117,8 +166,8 @@ body{ } .rooms-filter-btn:hover{ - background: #0a5e57; - border-color: #0a5e57; + background: var(--app-primary-hover); + border-color: var(--app-primary-hover); } .rooms-filter-clear{ @@ -200,23 +249,132 @@ body{ } .dashboard-card-blue{ - background-color: #2d5be3; + background-color: #1000ff !important; } .dashboard-card-green{ - background-color: #00ab74; + background-color: #00ab74 !important; } .dashboard-card-amber{ - background-color: #eeb258; + background-color: #eeb258 !important; } .dashboard-card-coral{ - background-color: #ff7f7f; + background-color: #ff7f7f !important; } .dashboard-card-teal{ - background-color: #0f766e; + background-color: #005f73 !important; +} + +.navbar{ + background-color: var(--app-navbar-bg) !important; +} + +.navbar .navbar-brand, +.navbar .nav-link{ + color: var(--app-navbar-text) !important; +} + +.navbar .nav-link.active{ + color: var(--app-accent) !important; +} + +.nav-toolbar-btn{ + border: 1px solid var(--app-divider); + color: var(--app-navbar-text); + background: transparent; +} + +.nav-toolbar-btn:hover{ + color: var(--app-navbar-text); + background: var(--app-hover); +} + +/* Keep original toolbar colors in both light and dark modes */ +.navbar{ + background-color: #212529 !important; +} + +.navbar .navbar-brand, +.navbar .nav-link, +.navbar .nav-link.active{ + color: #f8f9fa !important; +} + +.navbar form .form-control{ + background-color: #ffffff; + color: #212529; + border-color: #ced4da; +} + +.navbar form .form-control::placeholder{ + color: #6c757d; +} + +.navbar form .form-control:focus{ + background-color: #ffffff; + color: #212529; + border-color: #86b7fe; + box-shadow: 0 0 0 0.2rem rgba(13, 110, 253, 0.25); +} + +.nav-toolbar-btn{ + border: 1px solid #f8f9fa; + color: #f8f9fa; + background: transparent; +} + +.nav-toolbar-btn:hover{ + color: #212529; + background: #f8f9fa; +} + +.theme-toggle-btn{ + width: 42px; + height: 42px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.theme-toggle-btn i{ + font-size: 1.05rem; +} + +.card{ + background-color: var(--app-surface); + border-color: var(--app-border); + color: var(--app-text); +} + +.form-control{ + background-color: var(--app-surface); + color: var(--app-text); + border-color: var(--app-border); +} + +.form-control::placeholder{ + color: var(--app-text-soft); +} + +.form-control:focus{ + background-color: var(--app-surface); + color: var(--app-text); +} + +[data-theme="dark"] .rooms-hero{ + background: + linear-gradient(130deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%), + linear-gradient(135deg, #1b2330 0%, #17202b 100%); + box-shadow: 0 12px 30px rgba(2, 6, 23, 0.45); +} + +[data-theme="dark"] .rooms-type-chip{ + background: var(--app-primary-soft); + color: #d9f2ff; } .contact-edit-card input, @@ -232,8 +390,8 @@ body{ .contact-edit-card input:focus, .contact-edit-card select:focus, .contact-edit-card textarea:focus{ - border-color: #0f766e; - box-shadow: 0 0 0 0.2rem rgba(15, 118, 110, 0.15); + border-color: var(--app-primary); + box-shadow: 0 0 0 0.2rem rgba(47, 110, 165, 0.22); outline: none; } @@ -250,8 +408,8 @@ body{ .booking-create-card input:focus, .booking-create-card select:focus, .booking-create-card textarea:focus{ - border-color: #0f766e; - box-shadow: 0 0 0 0.2rem rgba(15, 118, 110, 0.15); + border-color: var(--app-primary); + box-shadow: 0 0 0 0.2rem rgba(47, 110, 165, 0.22); outline: none; } @@ -259,19 +417,19 @@ body{ display: inline-flex; align-items: center; gap: 0.6rem; - background: #f4f9f8; - border: 1px solid #d9e1e7; + background: var(--app-bg-secondary); + border: 1px solid var(--app-border); border-radius: 999px; padding: 0.35rem 0.9rem; } .booking-days-label{ - color: #6c757d; + color: var(--app-muted); font-weight: 500; } .booking-days-value{ - color: #0f766e; + color: var(--app-accent); font-size: 1.1rem; font-weight: 700; min-width: 1.4rem; diff --git a/pms/statics/js/theme_mode.js b/pms/statics/js/theme_mode.js new file mode 100644 index 000000000..bb47a2313 --- /dev/null +++ b/pms/statics/js/theme_mode.js @@ -0,0 +1,43 @@ +(function () { + function getCookie(name) { + var value = "; " + document.cookie; + var parts = value.split("; " + name + "="); + if (parts.length === 2) return parts.pop().split(";").shift(); + return null; + } + + function setCookie(name, value, days) { + var maxAge = days * 24 * 60 * 60; + document.cookie = name + "=" + value + "; path=/; max-age=" + maxAge + "; SameSite=Lax"; + } + + function applyTheme(theme) { + document.documentElement.setAttribute("data-theme", theme); + var icon = document.getElementById("theme-toggle-icon"); + if (!icon) return; + + if (theme === "dark") { + icon.classList.remove("bi-sun-fill"); + icon.classList.add("bi-moon-stars-fill"); + } else { + icon.classList.remove("bi-moon-stars-fill"); + icon.classList.add("bi-sun-fill"); + } + } + + function initThemeToggle() { + var button = document.getElementById("theme-toggle-btn"); + var currentTheme = document.documentElement.getAttribute("data-theme") || getCookie("theme_mode") || "light"; + applyTheme(currentTheme); + + if (!button) return; + button.addEventListener("click", function () { + var activeTheme = document.documentElement.getAttribute("data-theme") || "light"; + var nextTheme = activeTheme === "dark" ? "light" : "dark"; + applyTheme(nextTheme); + setCookie("theme_mode", nextTheme, 365); + }); + } + + document.addEventListener("DOMContentLoaded", initThemeToggle); +})(); diff --git a/pms/templates/main.html b/pms/templates/main.html index b2216a759..04189fd2c 100644 --- a/pms/templates/main.html +++ b/pms/templates/main.html @@ -4,6 +4,19 @@ PMS {% load static %} + @@ -35,9 +48,12 @@ Habitaciones -
+ - + +
@@ -48,5 +64,6 @@ {% endblock %}
+ - \ No newline at end of file + From 25b72742ce986ef7079ecbcd8d4875ba7fca4b03 Mon Sep 17 00:00:00 2001 From: ArielHS Date: Tue, 3 Mar 2026 01:48:35 +0000 Subject: [PATCH 6/7] chore(dark-light): restaurar vistas funcionales al estado original - Revierte forms, views, urls, tests y templates de negocio para que coincidan con main - Elimina la plantilla edit_booking_dates de este branch - Mantiene este branch enfocado en modo claro/oscuro y capa visual --- pms/forms.py | 59 +--------- pms/templates/booking_search_form.html | 43 +++---- pms/templates/dashboard.html | 36 +++--- pms/templates/edit_booking.html | 39 +++---- pms/templates/edit_booking_dates.html | 38 ------- pms/templates/home.html | 4 +- pms/templates/rooms.html | 57 +++------- pms/tests.py | 151 +------------------------ pms/urls.py | 1 - pms/views.py | 73 +----------- 10 files changed, 69 insertions(+), 432 deletions(-) delete mode 100644 pms/templates/edit_booking_dates.html diff --git a/pms/forms.py b/pms/forms.py index b4c3946c6..f1bc68d08 100644 --- a/pms/forms.py +++ b/pms/forms.py @@ -1,4 +1,4 @@ -from datetime import date, datetime +from datetime import datetime from django import forms from django.forms import ModelForm @@ -56,60 +56,3 @@ 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", - } - ), - ) - - -class BookingDatesEditForm(forms.Form): - checkin = forms.DateField( - label="Fecha de entrada", - input_formats=["%Y-%m-%d"], - widget=forms.DateInput( - format="%Y-%m-%d", - attrs={ - "type": "date", - "max": date(2026, 12, 31).strftime("%Y-%m-%d"), - "class": "form-control", - } - ), - ) - checkout = forms.DateField( - label="Fecha de salida", - input_formats=["%Y-%m-%d"], - widget=forms.DateInput( - format="%Y-%m-%d", - attrs={ - "type": "date", - "max": date(2026, 12, 31).strftime("%Y-%m-%d"), - "class": "form-control", - } - ), - ) - - def clean(self): - cleaned_data = super().clean() - checkin = cleaned_data.get("checkin") - checkout = cleaned_data.get("checkout") - if not checkin or not checkout: - return cleaned_data - - if checkout <= checkin: - raise forms.ValidationError("La fecha de salida debe ser posterior a la fecha de entrada") - - max_allowed_date = date(2026, 12, 31) - if checkin > max_allowed_date or checkout > max_allowed_date: - raise forms.ValidationError("Solo se permiten reservas hasta el 31/12/2026") - - return cleaned_data diff --git a/pms/templates/booking_search_form.html b/pms/templates/booking_search_form.html index b718fc698..ed00bdbfc 100644 --- a/pms/templates/booking_search_form.html +++ b/pms/templates/booking_search_form.html @@ -1,33 +1,24 @@ {% extends "main.html"%} {% block content %} -
-
-

Nueva reserva

-

Selecciona fechas y cantidad de huéspedes para ver disponibilidad.

+

Nueva reserva

+
+ {% csrf_token%} + {% for field in form %} +
+
{{field.label_tag}}
+
{{field}}
- -
- - {% csrf_token%} - {% for field in form %} -
- - {{ field }} -
- {% endfor %} - -
- Días de estancia - 0 -
- -
- -
- + {% endfor %} +
+
Días:
+
+
+
+ +
-
+ {% load static %} -{% endblock content%} +{% endblock content%} \ No newline at end of file diff --git a/pms/templates/dashboard.html b/pms/templates/dashboard.html index 30b3f33af..10f0285cc 100644 --- a/pms/templates/dashboard.html +++ b/pms/templates/dashboard.html @@ -1,33 +1,27 @@ {% extends "main.html"%} {% block content %} -
-
-

Dashboard

-

Resumen operativo del día y nivel de ocupación actual.

-
- -
-
+

Dashboard

+
+
Hoy
+
+
Reservas hechas
-

{{ dashboard.new_bookings }}

+

{{dashboard.new_bookings}}

-
+
Huéspedes ingresando
-

{{ dashboard.incoming_guests }}

+

{{dashboard.incoming_guests}}

-
+
Huéspedes saliendo
-

{{ dashboard.outcoming_guests }}

+

{{dashboard.outcoming_guests}}

-
+ +
Total facturado
-

€ {% if dashboard.invoiced.total__sum == None %}0.00{% endif %} {{ dashboard.invoiced.total__sum|floatformat:2 }}

-
-
-
% ocupación
-

{{ dashboard.occupancy_percentage|floatformat:2 }}%

+

€ {% if dashboard.invoiced.total__sum == None %}0.00{% endif %} {{dashboard.invoiced.total__sum|floatformat:2}}

-
-{% endblock content%} +
+{% endblock content%} \ No newline at end of file diff --git a/pms/templates/edit_booking.html b/pms/templates/edit_booking.html index bdf89b393..5c591f356 100644 --- a/pms/templates/edit_booking.html +++ b/pms/templates/edit_booking.html @@ -1,30 +1,23 @@ {% extends "main.html"%} {% block content %} -
-
-

Editar datos de contacto

-

Actualiza la información del cliente asociada a la reserva.

+

Editar datos de contacto

+
+ + {% csrf_token%} + {% for field in customer_form %} +
+
+ {{field.label_tag}} +
+
+ {{field}} +
+ {% endfor %} + Volver + +
-
-
- {% csrf_token%} - {% for field in customer_form %} -
- - {{ field }} - {% for error in field.errors %} -
{{ error }}
- {% endfor %} -
- {% endfor %} -
- Volver - -
-
-
-
{% endblock content%} diff --git a/pms/templates/edit_booking_dates.html b/pms/templates/edit_booking_dates.html deleted file mode 100644 index 18cdd00c8..000000000 --- a/pms/templates/edit_booking_dates.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends "main.html"%} - -{% block content %} -
-
-

Editar fechas de reserva

-

Reserva {{ booking.code }} - {{ booking.room.name }}

-
- -
- {% if form.non_field_errors %} -
- {% for error in form.non_field_errors %} -
{{ error }}
- {% endfor %} -
- {% endif %} - -
- {% csrf_token %} - {% for field in form %} -
- - {{ field }} - {% for error in field.errors %} -
{{ error }}
- {% endfor %} -
- {% endfor %} - -
- Volver - -
-
-
-
-{% endblock content %} diff --git a/pms/templates/home.html b/pms/templates/home.html index e57e55ac0..1e61b8024 100644 --- a/pms/templates/home.html +++ b/pms/templates/home.html @@ -68,7 +68,7 @@

Reservas Realizadas

Editar datos de contacto
- Editar fechas +
@@ -84,4 +84,4 @@

Reservas Realizadas

-{% endblock content%} +{% endblock content%} \ No newline at end of file diff --git a/pms/templates/rooms.html b/pms/templates/rooms.html index e38d03212..c30929f1f 100644 --- a/pms/templates/rooms.html +++ b/pms/templates/rooms.html @@ -1,50 +1,19 @@ {% extends "main.html"%} {% block content %} -
-
-

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 %} +

Habitaciones del hotel

+{% for room in rooms%} +
+
+
+ {{room.name}} ({{room.room_type__name}})
+ +
- - {% if rooms %} -
- {% for room in rooms %} - - {% endfor %} -
- {% else %} - - {% endif %} -
+ + +{% endfor %} {% endblock content%} diff --git a/pms/tests.py b/pms/tests.py index 5ae92b3d7..7ce503c2d 100644 --- a/pms/tests.py +++ b/pms/tests.py @@ -1,152 +1,3 @@ from django.test import TestCase -from django.test.utils import override_settings -from django.urls import reverse -from datetime import date, timedelta -from .models import Booking, 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.") - - -@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") -class DashboardOccupancyTests(TestCase): - @classmethod - def setUpTestData(cls): - room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) - room_1 = Room.objects.create(room_type=room_type, name="Room 1.1", description="Desc") - room_2 = Room.objects.create(room_type=room_type, name="Room 1.2", description="Desc") - today = date.today() - - Booking.objects.create( - state="NEW", - checkin=today, - checkout=today + timedelta(days=1), - room=room_1, - guests=1, - total=20, - code="CONF0001", - ) - Booking.objects.create( - state="DEL", - checkin=today, - checkout=today + timedelta(days=1), - room=room_2, - guests=1, - total=20, - code="CANC0001", - ) - - def test_dashboard_displays_occupancy_percentage_widget(self): - response = self.client.get(reverse("dashboard")) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "% ocupación") - self.assertContains(response, "50.00%") - self.assertEqual(response.context["dashboard"]["occupancy_percentage"], 50.0) - - -@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") -class DashboardOccupancyWithoutRoomsTests(TestCase): - def test_dashboard_occupancy_is_zero_when_no_rooms_exist(self): - response = self.client.get(reverse("dashboard")) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.context["dashboard"]["occupancy_percentage"], 0) - self.assertContains(response, "0.00%") - - -@override_settings(STATICFILES_STORAGE="django.contrib.staticfiles.storage.StaticFilesStorage") -class EditBookingDatesTests(TestCase): - @classmethod - def setUpTestData(cls): - room_type = Room_type.objects.create(name="Simple", price=20, max_guests=1) - cls.room = Room.objects.create(room_type=room_type, name="Room 1.1", description="Desc") - cls.booking = Booking.objects.create( - state="NEW", - checkin=date.today() + timedelta(days=5), - checkout=date.today() + timedelta(days=7), - room=cls.room, - guests=1, - total=40, - code="EDIT0001", - ) - - def test_edit_dates_view_updates_booking_when_room_is_available(self): - response = self.client.post( - reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), - data={ - "checkin": date.today() + timedelta(days=10), - "checkout": date.today() + timedelta(days=12), - }, - ) - self.assertEqual(response.status_code, 302) - self.assertEqual(response.url, "/") - - self.booking.refresh_from_db() - self.assertEqual(self.booking.checkin, date.today() + timedelta(days=10)) - self.assertEqual(self.booking.checkout, date.today() + timedelta(days=12)) - self.assertEqual(self.booking.total, 40) - - def test_edit_dates_view_renders_iso_values_for_date_inputs(self): - response = self.client.get(reverse("edit_booking_dates", kwargs={"pk": self.booking.id})) - self.assertEqual(response.status_code, 200) - self.assertContains(response, f'value="{self.booking.checkin.strftime("%Y-%m-%d")}"') - self.assertContains(response, f'value="{self.booking.checkout.strftime("%Y-%m-%d")}"') - - def test_edit_dates_view_displays_availability_error_when_dates_overlap(self): - Booking.objects.create( - state="NEW", - checkin=date.today() + timedelta(days=8), - checkout=date.today() + timedelta(days=11), - room=self.room, - guests=1, - total=60, - code="OVLP0001", - ) - response = self.client.post( - reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), - data={ - "checkin": date.today() + timedelta(days=9), - "checkout": date.today() + timedelta(days=10), - }, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "No hay disponibilidad para las fechas seleccionadas") - - def test_edit_dates_view_displays_range_error_for_dates_after_2026_12_31(self): - response = self.client.post( - reverse("edit_booking_dates", kwargs={"pk": self.booking.id}), - data={ - "checkin": date(2026, 12, 30), - "checkout": date(2027, 1, 2), - }, - ) - self.assertEqual(response.status_code, 200) - self.assertContains(response, "Solo se permiten reservas hasta el 31/12/2026") +# Create your tests here. diff --git a/pms/urls.py b/pms/urls.py index 2acbd0a22..c18714abf 100644 --- a/pms/urls.py +++ b/pms/urls.py @@ -8,7 +8,6 @@ path("search/booking/", views.BookingSearchView.as_view(), name="booking_search"), path("booking//", views.BookingView.as_view(), name="booking"), path("booking//edit", views.EditBookingView.as_view(), name="edit_booking"), - path("booking//edit-dates", views.EditBookingDatesView.as_view(), name="edit_booking_dates"), path("booking//delete", views.DeleteBookingView.as_view(), name="delete_booking"), path("rooms/", views.RoomsView.as_view(), name="rooms"), path("room//", views.RoomDetailsView.as_view(), name="room_details"), diff --git a/pms/views.py b/pms/views.py index cdfcddfda..f38563933 100644 --- a/pms/views.py +++ b/pms/views.py @@ -174,51 +174,6 @@ def post(self, request, pk): return redirect("/") -class EditBookingDatesView(View): - def get(self, request, pk): - booking = Booking.objects.get(id=pk) - form = BookingDatesEditForm( - initial={ - "checkin": booking.checkin, - "checkout": booking.checkout, - } - ) - context = { - "booking": booking, - "form": form, - } - return render(request, "edit_booking_dates.html", context) - - def post(self, request, pk): - booking = Booking.objects.get(id=pk) - form = BookingDatesEditForm(request.POST) - if form.is_valid(): - checkin = form.cleaned_data["checkin"] - checkout = form.cleaned_data["checkout"] - - has_overlap = (Booking.objects - .filter(room=booking.room, state="NEW") - .exclude(id=booking.id) - .filter(checkin__lte=checkout, checkout__gte=checkin) - .exists()) - - if has_overlap: - form.add_error(None, "No hay disponibilidad para las fechas seleccionadas") - else: - total_days = (checkout - checkin).days - booking.checkin = checkin - booking.checkout = checkout - booking.total = total_days * booking.room.room_type.price - booking.save(update_fields=["checkin", "checkout", "total"]) - return redirect("/") - - context = { - "booking": booking, - "form": form, - } - return render(request, "edit_booking_dates.html", context) - - class DashboardView(View): def get(self, request): from datetime import date, time, datetime @@ -253,22 +208,13 @@ def get(self, request): .exclude(state="DEL") .aggregate(Sum('total')) ) - confirmed_bookings = (Booking.objects - .filter(state="NEW") - .values("id") - ).count() - total_rooms = Room.objects.values("id").count() - occupancy_percentage = 0 - if total_rooms > 0: - occupancy_percentage = (confirmed_bookings / total_rooms) * 100 # preparing context data dashboard = { 'new_bookings': new_bookings, 'incoming_guests': incoming, 'outcoming_guests': outcoming, - 'invoiced': invoiced, - 'occupancy_percentage': occupancy_percentage, + 'invoiced': invoiced } @@ -292,20 +238,9 @@ def get(self, request, pk): class RoomsView(View): def get(self, request): - 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") + # renders a list of rooms + rooms = Room.objects.all().values("name", "room_type__name", "id") context = { - 'rooms': rooms, - 'room_filter_form': room_filter_form, - 'room_name_filter': room_name, - 'results_count': results_count, + 'rooms': rooms } return render(request, "rooms.html", context) From 897f28e3434701cbe947be1c4c1268c8880d7966 Mon Sep 17 00:00:00 2001 From: ArielHS Date: Tue, 3 Mar 2026 10:42:21 +0000 Subject: [PATCH 7/7] docs(pr4): documentar sistema de temas claro/oscuro para PM y arquitectura MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Describe alcance visual, persistencia por cookie e integración en layout - Enumera archivos de UI impactados - Resume pruebas y valor esperado para producto --- docs/pr-4-dark-light-modes.md | 48 +++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 docs/pr-4-dark-light-modes.md diff --git a/docs/pr-4-dark-light-modes.md b/docs/pr-4-dark-light-modes.md new file mode 100644 index 000000000..84d066d1f --- /dev/null +++ b/docs/pr-4-dark-light-modes.md @@ -0,0 +1,48 @@ +# PR 4 - Dark/Light Modes + +## Objetivo de negocio +Incorporar un sistema de tema visual claro/oscuro con persistencia de preferencia del usuario para mejorar confort de uso, continuidad de experiencia y percepción de calidad del producto. + +## Alcance implementado +- Se agregó selector de tema en la toolbar superior (a la derecha del botón **Buscar**). +- El selector alterna entre: + - icono **sol** para modo claro, + - icono **luna** para modo oscuro. +- La preferencia se persiste en cookie de navegador y se reaplica automáticamente al cargar la app. +- Se mantuvo la interfaz funcional original, encapsulando el cambio en capa visual/estilos. + +## Cambios funcionales y técnicos +1. **Integración en layout principal** +- Se incorporó botón de toggle de tema en `main.html`. +- Se añadió inicialización temprana del tema para evitar parpadeo visual al cargar. + +2. **Lógica de persistencia** +- Se creó script dedicado para: + - leer preferencia desde cookie, + - alternar tema, + - actualizar cookie con duración anual. + +3. **Sistema de estilos por tema** +- Se evolucionó `style.css` a un esquema basado en variables, con reglas para ambos modos. +- Se aseguró consistencia visual y contraste en componentes base sin modificar lógica de negocio. + +4. **Criterio de estabilidad visual** +- Se preservaron colores originales de elementos clave solicitados (toolbar, estados críticos y widgets del dashboard) para mantener familiaridad operativa. + +## Archivos modificados +- `pms/templates/main.html` +- `pms/statics/css/style.css` +- `pms/statics/js/theme_mode.js` (nuevo) + +## Pruebas realizadas +- Validación funcional manual en navegador: + - toggle claro/oscuro, + - cambio de icono sol/luna, + - persistencia de preferencia tras recarga. +- Verificación de configuración Django: + - `python manage.py check` + +## Resultado esperado para el producto +- Mejor ergonomía visual para diferentes contextos de trabajo (día/noche). +- Experiencia más moderna y adaptable, alineada a estándares de productos SaaS. +- Base de theming reutilizable para futuras evoluciones UI sin reescribir templates funcionales.