diff --git a/api/apps/trips/migrations/0010_merge_20260628_1708.py b/api/apps/trips/migrations/0010_merge_20260628_1708.py new file mode 100644 index 00000000..091829d9 --- /dev/null +++ b/api/apps/trips/migrations/0010_merge_20260628_1708.py @@ -0,0 +1,13 @@ +# Generated by Django 6.0.6 on 2026-06-28 20:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("trips", "0009_route_max_bus"), + ("trips", "0009_trip_access_code_trip_is_private_trip_manager_and_more"), + ] + + operations = [] diff --git a/api/apps/trips/migrations/0011_guestpassenger_email.py b/api/apps/trips/migrations/0011_guestpassenger_email.py new file mode 100644 index 00000000..0ff9b414 --- /dev/null +++ b/api/apps/trips/migrations/0011_guestpassenger_email.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.6 on 2026-06-29 01:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("trips", "0010_merge_20260628_1708"), + ] + + operations = [ + migrations.AddField( + model_name="guestpassenger", + name="email", + field=models.EmailField(max_length=254, null=True), + ), + ] diff --git a/api/apps/trips/models.py b/api/apps/trips/models.py index d03b396d..d528e03d 100644 --- a/api/apps/trips/models.py +++ b/api/apps/trips/models.py @@ -242,6 +242,7 @@ def create_passenger(self, cpf, trip, **extra_fields): class GuestPassenger(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) cpf = models.CharField(max_length=11) + email = models.EmailField(null=True) trip = models.ForeignKey(to=Trip, on_delete=models.CASCADE) recorded_by = models.ForeignKey( to=CivilServantProfile, on_delete=models.SET_NULL, null=True diff --git a/api/apps/trips/serializers.py b/api/apps/trips/serializers.py index caa02733..a634c06b 100644 --- a/api/apps/trips/serializers.py +++ b/api/apps/trips/serializers.py @@ -582,4 +582,4 @@ class GuestPassengerSerializer(serializers.ModelSerializer): class Meta: model = GuestPassenger - fields = ("id", "cpf", "full_name") + fields = ("id", "cpf", "full_name", "email") diff --git a/api/apps/trips/views.py b/api/apps/trips/views.py index 3ddecd20..88905951 100644 --- a/api/apps/trips/views.py +++ b/api/apps/trips/views.py @@ -620,6 +620,8 @@ class GuestPassengerView(APIView): def post(self, request): trip_id = request.data.get("trip") cpf = request.data.get("cpf") + email = request.data.get("email") + if not trip_id: return Response( {"trip": ["This field is required."]}, @@ -632,9 +634,16 @@ def post(self, request): status=status.HTTP_400_BAD_REQUEST, ) + if not email: + return Response( + {"email": ["This field is required."]}, + status=status.HTTP_400_BAD_REQUEST, + ) + passenger = GuestPassenger.objects.create( cpf=cpf, trip_id=trip_id, + email=email, recorded_by=request.user.civil_servant_profile, full_name=request.data.get("full_name"), ) diff --git a/frontend/components/current-trip/CurrentTripPage.tsx b/frontend/components/current-trip/CurrentTripPage.tsx index a47bb912..7fcb0a5a 100644 --- a/frontend/components/current-trip/CurrentTripPage.tsx +++ b/frontend/components/current-trip/CurrentTripPage.tsx @@ -14,8 +14,9 @@ import { Separator } from "@/lib/ui/separator"; import { apiFetch } from "@/lib/api"; import { formatTripDate } from "@/features/user-home/config"; import PassengerQRCode from "@/lib/ui/qr-code"; -import { GuestForm } from "./GuestForm"; -import { useAuthStore } from "@/features/auth/store/auth-store"; +import { GuestForm } from "@/components/utils/GuestForm"; +import { Dialog, DialogContent, DialogTrigger } from "@/lib/ui/dialog"; +import { Textarea } from "@/lib/ui/textarea"; type PassengerGuest = { id: string; @@ -63,10 +64,10 @@ function getErrorMessage(detail?: string) { export function CurrentTripPage() { const [trip, setTrip] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [addedGuest, setAddedGuest] = useState(false); const [error, setError] = useState(null); const showMinutesCard = trip !== null && trip.minutes_remaining !== null; - const user = useAuthStore((state) => state.user); const isTripInProgress = trip?.status_trip?.toLowerCase() === "em andamento"; const percentage = Math.min( 100, @@ -299,9 +300,15 @@ export function CurrentTripPage() { - {!isTripInProgress && user?.profile_type === "CIVIL-SERVANT" && ( - setRefreshKey((old) => old + 1)} /> - )} + + + + + +{!addedGuest && ( {setAddedGuest(true)}} />)} +{addedGuest && ()} + + ) : (
diff --git a/frontend/components/trips/TripsHomePage.tsx b/frontend/components/trips/TripsHomePage.tsx index f397029b..9a16ff9d 100644 --- a/frontend/components/trips/TripsHomePage.tsx +++ b/frontend/components/trips/TripsHomePage.tsx @@ -1,75 +1,86 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState } from "react"; -import AppLayout from '@layout/app-layout' -import { FieldDescription } from '@ui/field' -import { Button } from '@ui/button' -import { apiFetch } from '@/lib/api' +import AppLayout from "@layout/app-layout"; +import { FieldDescription } from "@ui/field"; +import { Button } from "@ui/button"; +import { apiFetch } from "@/lib/api"; +import { GuestForm } from "@/components/utils/GuestForm"; +import { Dialog, DialogContent, DialogTrigger } from "@/lib/ui/dialog"; +import { Textarea } from "@/lib/ui/textarea"; type AvailableTrip = { - id: number - trip_date: string - origin: string - destiny: string - departure_time: string - bus_brand: string - status_trip: string - available_seats: number - is_reservable: boolean - quorum_met?: boolean - reservation_deadline?: string - user_is_reserved?: boolean -} + id: number; + trip_date: string; + origin: string; + destiny: string; + departure_time: string; + bus_brand: string; + status_trip: string; + available_seats: number; + is_reservable: boolean; + quorum_met?: boolean; + reservation_deadline?: string; + user_is_reserved?: boolean; +}; export function TripsHomePage() { - const [trips, setTrips] = useState([]) - const [isLoading, setIsLoading] = useState(false) - const [error, setError] = useState(null) - const [savingTripId, setSavingTripId] = useState(null) - const [reservedTripIds, setReservedTripIds] = useState([]) + const [trips, setTrips] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [addedGuest, setAddedGuest] = useState(false); + const [error, setError] = useState(null); + const [savingTripId, setSavingTripId] = useState(null); + const [reservedTripIds, setReservedTripIds] = useState([]); useEffect(() => { const loadTrips = async () => { - setError(null) - setIsLoading(true) + setError(null); + setIsLoading(true); try { - const data = await apiFetch('/reservations/available-trips/') - setTrips(data) + const data = await apiFetch( + "/reservations/available-trips/", + ); + setTrips(data); } catch (err) { - console.error('Erro ao carregar viagens disponíveis:', err) + console.error("Erro ao carregar viagens disponíveis:", err); - const errorData = err as { data?: { detail?: string } } | undefined - const detail = errorData?.data?.detail - const message = detail || 'Não foi possível carregar as viagens no momento.' - setError(message) + const errorData = err as { data?: { detail?: string } } | undefined; + const detail = errorData?.data?.detail; + const message = + detail || "Não foi possível carregar as viagens no momento."; + setError(message); } finally { - setIsLoading(false) + setIsLoading(false); } - } + }; - loadTrips() - }, []) + loadTrips(); + }, []); const handleReserve = async (tripId: number) => { - setSavingTripId(tripId) - setError(null) + setSavingTripId(tripId); + setError(null); try { - await apiFetch('/reservations/', { - method: 'POST', + await apiFetch("/reservations/", { + method: "POST", body: JSON.stringify({ trip: tripId }), - }) - setReservedTripIds((prev) => [...prev, tripId]) - - const data = await apiFetch('/reservations/available-trips/') - setTrips(data) + }); + setReservedTripIds((prev) => [...prev, tripId]); + + const data = await apiFetch( + "/reservations/available-trips/", + ); + setTrips(data); } catch (err) { - const errorData = err as { data?: { detail?: string } } | undefined - setError(errorData?.data?.detail || 'Não foi possível reservar esta viagem.') + const errorData = err as { data?: { detail?: string } } | undefined; + setError( + errorData?.data?.detail || "Não foi possível reservar esta viagem.", + ); } finally { - setSavingTripId(null) + setSavingTripId(null); } - } + }; return ( @@ -103,7 +114,8 @@ export function TripsHomePage() { ) : (
{trips.map((trip) => { - const isAlreadyReserved = trip.user_is_reserved || reservedTripIds.includes(trip.id) + const isAlreadyReserved = + trip.user_is_reserved || reservedTripIds.includes(trip.id); return (

- {trip.trip_date} • {trip.departure_time} • {trip.bus_brand} + {trip.trip_date} • {trip.departure_time} •{" "} + {trip.bus_brand}

Status da viagem: {trip.status_trip} @@ -131,45 +144,61 @@ export function TripsHomePage() { - +

+ + + + +{!addedGuest && ( {setAddedGuest(true)}} />)} +{addedGuest && ()} + +
- Quórum: {trip.quorum_met ? 'atingido' : 'pendente'} + Quórum: {trip.quorum_met ? "atingido" : "pendente"} {trip.reservation_deadline ? ( - Limite: {new Date(trip.reservation_deadline).toLocaleString('pt-BR')} + Limite:{" "} + {new Date(trip.reservation_deadline).toLocaleString( + "pt-BR", + )} ) : null}
- ) + ); })} )}

- As viagens exibidas respeitam o prazo de reserva e a disponibilidade de vagas no ônibus. + As viagens exibidas respeitam o prazo de reserva e a disponibilidade + de vagas no ônibus.

- ) -} \ No newline at end of file + ); +} diff --git a/frontend/components/current-trip/GuestForm.tsx b/frontend/components/utils/GuestForm.tsx similarity index 68% rename from frontend/components/current-trip/GuestForm.tsx rename to frontend/components/utils/GuestForm.tsx index a72abfa7..95d129f9 100644 --- a/frontend/components/current-trip/GuestForm.tsx +++ b/frontend/components/utils/GuestForm.tsx @@ -4,7 +4,6 @@ import { FieldLabel } from "@/lib/ui/field"; import { Input } from "@/lib/ui/input"; import { zodResolver } from "@hookform/resolvers/zod"; import { useMutation } from "@tanstack/react-query"; -import { useState } from "react"; import { useForm } from "react-hook-form"; import z from "zod"; @@ -20,11 +19,12 @@ const normalizeCpf = (value: string | undefined) => { const guestSchema = z.object({ full_name: z.string().nonempty("Informe o nome completo"), + email: z.email("Informe um Email válidgo"), cpf: z .string() .refine( (val) => val.replace(/\D/g, "").length === 11, - "CPF deve ter exatamente 11 números." + "CPF deve ter exatamente 11 números.", ), trip: z.number(), }); @@ -37,7 +37,6 @@ type Props = { }; export function GuestForm({ tripId, onGuestAdded }: Props) { - const [isAdding, setIsAdding] = useState(false); const guestForm = useForm({ resolver: zodResolver(guestSchema), @@ -46,6 +45,7 @@ export function GuestForm({ tripId, onGuestAdded }: Props) { trip: tripId, full_name: "", cpf: "", + email: "", }, }); @@ -59,8 +59,6 @@ export function GuestForm({ tripId, onGuestAdded }: Props) { }); }, onSuccess: () => { - guestForm.reset(); - setIsAdding(false); onGuestAdded(); }, onError: (error) => { @@ -77,31 +75,24 @@ export function GuestForm({ tripId, onGuestAdded }: Props) { const onSubmit = (data: GuestSchema) => { const payload = { ...data, - cpf: data.cpf.replace(/\D/g, ""), + cpf: data.cpf.replace(/\D/g, ""), }; addGuestMutation.mutate(payload); }; return ( <> - {!isAdding && ( - - )} - {isAdding && (
{ - setIsAdding(false); guestForm.reset(); }} >

Informações do Convidado

- +
Nome do convidado: )}
- -
- CPF do convidado: - { - e.target.value = normalizeCpf(e.target.value); - formOnChange(e); - }} - /> - {guestForm.formState.errors.cpf && ( -

- {guestForm.formState.errors.cpf.message} -

- )} -
- + +
+
+ CPF do convidado: + { + e.target.value = normalizeCpf(e.target.value); + formOnChange(e); + }} + /> + {guestForm.formState.errors.cpf && ( +

+ {guestForm.formState.errors.cpf.message} +

+ )} +
+
+ Email do Convidado + + {guestForm.formState.errors.email && ( +

+ {guestForm.formState.errors.email.message} +

+ )} +
+
+
-
- )} ); -} \ No newline at end of file +}