Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions api/apps/trips/migrations/0010_merge_20260628_1708.py
Original file line number Diff line number Diff line change
@@ -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 = []
18 changes: 18 additions & 0 deletions api/apps/trips/migrations/0011_guestpassenger_email.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions api/apps/trips/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion api/apps/trips/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -582,4 +582,4 @@ class GuestPassengerSerializer(serializers.ModelSerializer):

class Meta:
model = GuestPassenger
fields = ("id", "cpf", "full_name")
fields = ("id", "cpf", "full_name", "email")
9 changes: 9 additions & 0 deletions api/apps/trips/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."]},
Expand All @@ -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"),
)
Expand Down
19 changes: 13 additions & 6 deletions frontend/components/current-trip/CurrentTripPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -63,10 +64,10 @@ function getErrorMessage(detail?: string) {
export function CurrentTripPage() {
const [trip, setTrip] = useState<CurrentTripData | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [addedGuest, setAddedGuest] = useState(false);
const [error, setError] = useState<string | null>(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,
Expand Down Expand Up @@ -299,9 +300,15 @@ export function CurrentTripPage() {
</div>
</CardContent>
</Card>
{!isTripInProgress && user?.profile_type === "CIVIL-SERVANT" && (
<GuestForm tripId={trip.id} onGuestAdded={() => setRefreshKey((old) => old + 1)} />
)}
<Dialog>
<DialogTrigger asChild>
<Button className="w-full" onClick={() => {setAddedGuest(false)}}>Adicionar Convidado</Button>
</DialogTrigger>
<DialogContent>
{!addedGuest && (<GuestForm tripId={trip.id} onGuestAdded={() => {setAddedGuest(true)}} />)}
{addedGuest && (<Textarea className="color-green text-center">Convidado Adicionado!</Textarea>)}
</DialogContent>
</Dialog>
</div>
) : (
<div className="rounded-xl border border-dashed border-slate-200 bg-slate-50 p-8 text-center text-slate-600">
Expand Down
159 changes: 94 additions & 65 deletions frontend/components/trips/TripsHomePage.tsx
Original file line number Diff line number Diff line change
@@ -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<AvailableTrip[]>([])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [savingTripId, setSavingTripId] = useState<number | null>(null)
const [reservedTripIds, setReservedTripIds] = useState<number[]>([])
const [trips, setTrips] = useState<AvailableTrip[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [addedGuest, setAddedGuest] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savingTripId, setSavingTripId] = useState<number | null>(null);
const [reservedTripIds, setReservedTripIds] = useState<number[]>([]);

useEffect(() => {
const loadTrips = async () => {
setError(null)
setIsLoading(true)
setError(null);
setIsLoading(true);

try {
const data = await apiFetch<AvailableTrip[]>('/reservations/available-trips/')
setTrips(data)
const data = await apiFetch<AvailableTrip[]>(
"/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<AvailableTrip[]>('/reservations/available-trips/')
setTrips(data)
});
setReservedTripIds((prev) => [...prev, tripId]);

const data = await apiFetch<AvailableTrip[]>(
"/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 (
<AppLayout>
Expand Down Expand Up @@ -103,7 +114,8 @@ export function TripsHomePage() {
) : (
<div className="space-y-4">
{trips.map((trip) => {
const isAlreadyReserved = trip.user_is_reserved || reservedTripIds.includes(trip.id)
const isAlreadyReserved =
trip.user_is_reserved || reservedTripIds.includes(trip.id);

return (
<article
Expand All @@ -116,7 +128,8 @@ export function TripsHomePage() {
{trip.origin} &#8594; {trip.destiny}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{trip.trip_date} &#8226; {trip.departure_time} &#8226; {trip.bus_brand}
{trip.trip_date} &#8226; {trip.departure_time} &#8226;{" "}
{trip.bus_brand}
</p>
<p className="mt-1 text-sm text-muted-foreground">
Status da viagem: {trip.status_trip}
Expand All @@ -131,45 +144,61 @@ export function TripsHomePage() {
<Button
variant="default"
size="sm"
disabled={!trip.is_reservable || savingTripId === trip.id || isAlreadyReserved}
disabled={
!trip.is_reservable ||
savingTripId === trip.id ||
isAlreadyReserved
}
onClick={() => handleReserve(trip.id)}
className={
isAlreadyReserved
? "bg-teal-600 text-white disabled:opacity-100 disabled:bg-teal-600"
isAlreadyReserved
? "bg-teal-600 text-white disabled:opacity-100 disabled:bg-teal-600"
: ""
}
>
{isAlreadyReserved
? 'Reservado'
? "Reservado"
: !trip.is_reservable
? 'Indisponível'
? "Indisponível"
: savingTripId === trip.id
? 'Reservando...'
: 'Reservar'}
? "Reservando..."
: "Reservar"}
</Button>

<Dialog>
<DialogTrigger asChild>
<Button onClick={() => {setAddedGuest(false)}}>Adicionar Convidado</Button>
</DialogTrigger>
<DialogContent>
{!addedGuest && (<GuestForm tripId={trip.id} onGuestAdded={() => {setAddedGuest(true)}} />)}
{addedGuest && (<Textarea className="color-green text-center">Convidado Adicionado!</Textarea>)}
</DialogContent>
</Dialog>
</div>
</div>
<div className="mt-3 flex flex-wrap gap-2 text-xs text-muted-foreground">
<span className="rounded-full bg-muted px-3 py-1">
Quórum: {trip.quorum_met ? 'atingido' : 'pendente'}
Quórum: {trip.quorum_met ? "atingido" : "pendente"}
</span>
{trip.reservation_deadline ? (
<span className="rounded-full bg-muted px-3 py-1">
Limite: {new Date(trip.reservation_deadline).toLocaleString('pt-BR')}
Limite:{" "}
{new Date(trip.reservation_deadline).toLocaleString(
"pt-BR",
)}
</span>
) : null}
</div>
</article>
)
);
})}
</div>
)}

<p className="mt-8 text-center text-sm text-muted-foreground">
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.
</p>
</section>
</AppLayout>
)
}
);
}
Loading
Loading