From 44846ab8d03d3bc0864077f147f7c3fa12ca5ce2 Mon Sep 17 00:00:00 2001 From: Ruan Bueno Date: Mon, 22 Jun 2026 18:12:57 -0300 Subject: [PATCH] =?UTF-8?q?feat(cars):=20cat=C3=A1logo=20de=20reserva,=20a?= =?UTF-8?q?gendamento=20manual=20no=20admin=20e=20corre=C3=A7=C3=B5es=20de?= =?UTF-8?q?=20filial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reserva (/cars): exibe o catálogo de veículos direto (sem gate de filtro); ao reservar, pede o intervalo explícito (início + devolução) e aponta Disponível/Ocupado via nova query vehicleRent.checkAvailability. - Admin: agendamento manual em nome de outro usuário (vehicleRent.createForUser) + componente ManualRentForm; badge "Sem filial" nos veículos legados. - Corrige edição de veículo: empresa/filial agora vêm preenchidas (empresa derivada da filial salva, sem corrida de efeitos). - Backfill de filial para veículos legados quando o mapeamento é inequívoco. - Calendário de /cars: cada reserva ganha cor própria estendida por todo o intervalo (start → possibleEnd). Co-Authored-By: Claude Opus 4.8 --- package.json | 2 +- .../migration.sql | 20 ++ .../(authenticated)/admin/vehicles/page.tsx | 70 ++-- src/app/(authenticated)/cars/page.tsx | 321 +++++++----------- .../admin/vehicles/manual-rent-form.tsx | 234 +++++++++++++ .../admin/vehicles/vehicle-form.tsx | 36 +- src/components/vehicles/rent-form.tsx | 201 ++++++++--- src/components/vehicles/vehicle-calendar.tsx | 107 +++++- src/schemas/vehicle-rent.schema.ts | 7 + src/server/api/routers/vehicle-rent.ts | 238 ++++++++----- 10 files changed, 837 insertions(+), 399 deletions(-) create mode 100644 prisma/migrations/20260622120000_backfill_vehicle_filial/migration.sql create mode 100644 src/components/admin/vehicles/manual-rent-form.tsx diff --git a/package.json b/package.json index fbb72df3..9960285d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "elo", - "version": "1.47.0", + "version": "1.50.1", "private": true, "type": "module", "scripts": { diff --git a/prisma/migrations/20260622120000_backfill_vehicle_filial/migration.sql b/prisma/migrations/20260622120000_backfill_vehicle_filial/migration.sql new file mode 100644 index 00000000..194358ff --- /dev/null +++ b/prisma/migrations/20260622120000_backfill_vehicle_filial/migration.sql @@ -0,0 +1,20 @@ +-- Backfill de filial para veículos legados (filialId NULL) criados antes do +-- vínculo veículo→filial. Atribui automaticamente APENAS quando o mapeamento é +-- inequívoco: o `enterprise` legado do veículo corresponde a uma empresa que +-- possui exatamente UMA filial. Casos ambíguos (empresa com várias filiais) +-- permanecem NULL e devem ser ajustados manualmente pelo admin. +UPDATE "vehicles" v +SET "filialId" = sub."filialId" +FROM ( + SELECT f."id" AS "filialId", e."enterprise" AS "enterprise" + FROM "filiais" f + JOIN "empresas" e ON e."id" = f."empresaId" +) sub +WHERE v."filialId" IS NULL + AND v."enterprise" = sub."enterprise" + AND ( + SELECT COUNT(*) + FROM "filiais" f2 + JOIN "empresas" e2 ON e2."id" = f2."empresaId" + WHERE e2."enterprise" = v."enterprise" + ) = 1; diff --git a/src/app/(authenticated)/admin/vehicles/page.tsx b/src/app/(authenticated)/admin/vehicles/page.tsx index b2c148c2..97d0b5e3 100644 --- a/src/app/(authenticated)/admin/vehicles/page.tsx +++ b/src/app/(authenticated)/admin/vehicles/page.tsx @@ -1,7 +1,7 @@ "use client" import { useState } from "react" -import { Plus, Search, Edit, Trash2, BarChart3, Car, TrendingUp } from "lucide-react" +import { Plus, Search, Edit, Trash2, BarChart3, Car, TrendingUp, CalendarPlus } from "lucide-react" import Image from "next/image" import { api } from "@/trpc/react" import { Button } from "@/components/ui/button" @@ -11,6 +11,7 @@ import { Badge } from "@/components/ui/badge" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog" import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" import { VehicleForm } from "@/components/admin/vehicles/vehicle-form" +import { ManualRentForm } from "@/components/admin/vehicles/manual-rent-form" import { VehicleMetrics } from "@/components/admin/vehicles/vehicle-metrics" import { VehicleUsageHistory } from "@/components/admin/vehicles/vehicle-usage-history" import { UserRanking } from "@/components/admin/vehicles/user-ranking" @@ -23,6 +24,7 @@ export default function VehiclesAdminClient() { const [searchTerm, setSearchTerm] = useState("") const [selectedVehicle, setSelectedVehicle] = useState(null) const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false) + const [isRentDialogOpen, setIsRentDialogOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const [imageErrors, setImageErrors] = useState>(new Set()) @@ -101,26 +103,46 @@ export default function VehiclesAdminClient() { Gerencie a frota de veículos da empresa

- - - - - - - Criar Novo Veículo - - { - setIsCreateDialogOpen(false) - void refetch() - }} - onCancel={() => setIsCreateDialogOpen(false)} - /> - - +
+ + + + + + + Novo Agendamento + + setIsRentDialogOpen(false)} + onCancel={() => setIsRentDialogOpen(false)} + /> + + + + + + + + + + Criar Novo Veículo + + { + setIsCreateDialogOpen(false) + void refetch() + }} + onCancel={() => setIsCreateDialogOpen(false)} + /> + + +
@@ -209,7 +231,11 @@ export default function VehiclesAdminClient() {
Filial: - {vehicle.filial ? `${vehicle.filial.name} (${vehicle.filial.code})` : "—"} + {vehicle.filial ? ( + {`${vehicle.filial.name} (${vehicle.filial.code})`} + ) : ( + Sem filial + )}
Quilometragem: diff --git a/src/app/(authenticated)/cars/page.tsx b/src/app/(authenticated)/cars/page.tsx index 83d5cd76..2db8e73f 100644 --- a/src/app/(authenticated)/cars/page.tsx +++ b/src/app/(authenticated)/cars/page.tsx @@ -2,13 +2,10 @@ import Image from "next/image" import Link from "next/link" -import { Car, Calendar, LucideFileVideo, User2Icon, MapPin, PlusCircle } from "lucide-react" +import { Car, Calendar, LucideFileVideo, User2Icon, MapPin, Edit } from "lucide-react" import { api } from "@/trpc/react" import type { VehicleRent, Vehicle } from "@prisma/client" -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import { canViewCars, canLocateCars } from "@/lib/access-control" -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import type { RolesConfig } from "@/types/role-config" +import { canLocateCars } from "@/lib/access-control" import { Button } from "@/components/ui/button" import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" @@ -17,71 +14,53 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog import { VehicleCalendar } from "@/components/vehicles/vehicle-calendar" import { RentForm } from "@/components/vehicles/rent-form" import { EmpresaFilialFilter, type EmpresaFilialValue } from "@/components/ui/empresa-filial-filter" -import { Edit } from "lucide-react" import { useState } from "react" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" export default function DashboardPage() { + const utils = api.useUtils() const [isEditModalOpen, setIsEditModalOpen] = useState(false) const [selectedRent, setSelectedRent] = useState<(VehicleRent & { vehicle: Vehicle }) | null>(null) - const [selectedDate, setSelectedDate] = useState("") - const [selectedTime, setSelectedTime] = useState("") - const [filterApplied, setFilterApplied] = useState(false) + // Veículo escolhido para reservar (abre o modal de intervalo + disponibilidade) + const [reserveVehicle, setReserveVehicle] = useState(null) // Filtro no padrão novo (empresa → filial) const [empresaFilial, setEmpresaFilial] = useState({ empresaId: "", filialId: "" }) - // Buscar dados do usuário e reservas ativas + // Dados do usuário e reservas ativas const { data: userData } = api.user.me.useQuery() const { data: activeRent } = api.vehicleRent.getMyActiveRent.useQuery() - // Buscar veículos disponíveis quando filtro for aplicado - const { data: availableVehicles, isLoading: isLoadingVehicles } = api.vehicle.getAll.useQuery( - { - limit: 100, - availble: true, - checkDate: filterApplied ? selectedDate : undefined, - checkTime: filterApplied ? selectedTime : undefined, - }, - { - enabled: filterApplied, - } - ) + // Catálogo: todos os veículos disponíveis (sem exigir filtro de data antes) + const { data: vehiclesData, isLoading: isLoadingVehicles } = api.vehicle.getAll.useQuery({ + limit: 100, + availble: true, + }) // Verificar se o usuário tem permissão para fazer reservas const canReserve = userData ? canLocateCars(userData.role_config) : false - // Aplicar filtro de empresa/filial (padrão novo) sobre os veículos disponíveis - const filteredVehicles = (availableVehicles?.items ?? []).filter((vehicle) => { + // Filtro de empresa/filial (padrão novo) sobre o catálogo + const filteredVehicles = (vehiclesData?.items ?? []).filter((vehicle) => { if (empresaFilial.filialId) return vehicle.filialId === empresaFilial.filialId if (empresaFilial.empresaId) return vehicle.filial?.empresa.id === empresaFilial.empresaId return true }) - // Função para abrir o modal de edição + // Funções dos modais const openEditModal = (rent: VehicleRent & { vehicle: Vehicle }) => { setSelectedRent(rent) setIsEditModalOpen(true) } - // Função para fechar o modal const closeEditModal = () => { setIsEditModalOpen(false) setSelectedRent(null) } - // Função para aplicar filtros - const applyFilters = () => { - if (selectedDate && selectedTime) { - setFilterApplied(true) - } - } - - // Função para limpar filtros - const clearFilters = () => { - setSelectedDate("") - setSelectedTime("") - setFilterApplied(false) + // Fecha o modal de reserva e atualiza catálogo/reservas ativas + const closeReserveModal = () => { + setReserveVehicle(null) + void utils.vehicleRent.getMyActiveRent.invalidate() + void utils.vehicle.getAll.invalidate() } return ( @@ -99,9 +78,7 @@ export default function DashboardPage() { - - Tutorial: Reserva de carros - + Tutorial: Reserva de carros @@ -110,134 +87,10 @@ export default function DashboardPage() {
- {/* Filtros por Data e Horário */} - - - - - Filtrar Veículos Disponíveis - - - -
-
- - setSelectedDate(e.target.value)} - min={new Date().toISOString().split('T')[0]} - /> -
-
- - setSelectedTime(e.target.value)} - /> -
-
- - {(selectedDate || selectedTime) && ( - - )} -
-
-
-
- - {/* Resultados dos Filtros */} - {filterApplied && ( + {/* Reservas ativas do usuário */} + {activeRent && activeRent.length > 0 && ( - - - - Veículos Disponíveis ({filteredVehicles.length}) - - - -
- -
- {isLoadingVehicles ? ( -
-
-

Carregando veículos disponíveis...

-
- ) : filteredVehicles.length ? ( -
- {filteredVehicles.map((vehicle) => ( - -
- {vehicle.model} -
-
-
-
-

{vehicle.model}

-

{vehicle.plate}

-
- {vehicle.enterprise} -
-
- - {Number(vehicle.kilometers).toLocaleString()} km -
- -
-
- ))} -
- ) : ( -
- -

Nenhum veículo disponível

-

- Não há veículos disponíveis para a data e horário selecionados. -

-
- )} -
-
- )} - -
- - {activeRent?.map((actvRent: VehicleRent & { vehicle: Vehicle }, i: number) => ( + {activeRent.map((actvRent: VehicleRent & { vehicle: Vehicle }, i: number) => (
Veículo Reservado @@ -284,11 +137,7 @@ export default function DashboardPage() {
- @@ -297,41 +146,101 @@ export default function DashboardPage() {
))} - - {activeRent && activeRent.length > 0 && canReserve && ( -
- - - -
)} - {(!activeRent || activeRent.length === 0) && ( - - -

- {canReserve ? "Nenhum veículo reservado" : "Visualização de Veículos"} -

-

- {canReserve - ? "Você não possui nenhum veículo reservado no momento." - : "Você pode visualizar os veículos disponíveis, mas não possui permissão para fazer reservas." - } + + {/* Catálogo de veículos disponíveis para reserva */} + + + + + Veículos disponíveis para reserva ({filteredVehicles.length}) + + + +

+ +
+ + {isLoadingVehicles ? ( +
+
+

Carregando veículos...

+
+ ) : filteredVehicles.length ? ( +
+ {filteredVehicles.map((vehicle) => ( + +
+ {vehicle.model} +
+
+
+
+

{vehicle.model}

+

{vehicle.plate}

+
+ {vehicle.enterprise} +
+
+ + {Number(vehicle.kilometers).toLocaleString()} km +
+ +
+
+ ))} +
+ ) : ( +
+ +

Nenhum veículo disponível

+

+ Não há veículos disponíveis para o filtro selecionado. +

+
+ )} +
+
+ + {!canReserve && ( + + +

Visualização de Veículos

+

+ Você pode visualizar os veículos disponíveis, mas não possui permissão para fazer reservas.

- {canReserve && ( - - )}
)} + + {/* Modal de reserva: pede o intervalo e aponta disponibilidade */} + !open && closeReserveModal()}> + + + Reservar Veículo + + {reserveVehicle && ( + + )} + + + {/* Modal de edição de reserva */} @@ -349,7 +258,15 @@ export default function DashboardPage() { )} + + {/* Link auxiliar para a visão de catálogo dedicada (filtro por data/horário) */} + {canReserve && ( +
+ + Ver catálogo com filtro por data e horário específicos + +
+ )} ) } - diff --git a/src/components/admin/vehicles/manual-rent-form.tsx b/src/components/admin/vehicles/manual-rent-form.tsx new file mode 100644 index 00000000..2f070bc0 --- /dev/null +++ b/src/components/admin/vehicles/manual-rent-form.tsx @@ -0,0 +1,234 @@ +"use client" + +import { useMemo, useState } from "react" +import { Loader2 } from "lucide-react" + +import { api } from "@/trpc/react" +import { toast } from "sonner" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Switch } from "@/components/ui/switch" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { DateTimePicker } from "@/components/ui/date-time-picker" +import { EmpresaFilialFilter, type EmpresaFilialValue } from "@/components/ui/empresa-filial-filter" + +interface ManualRentFormProps { + /** Chamado após criar o agendamento com sucesso (ex.: fechar o modal). */ + onSuccess: () => void + onCancel: () => void +} + +/** + * Agendamento manual feito por um admin em nome de outro usuário. + * Reaproveita o filtro empresa→filial e os componentes do Design System. + */ +export function ManualRentForm({ onSuccess, onCancel }: ManualRentFormProps) { + // Usuário alvo do agendamento + const [searchQuery, setSearchQuery] = useState("") + const [selectedUser, setSelectedUser] = useState<{ id: string; firstName: string | null; lastName: string | null; email: string } | null>(null) + + // Filtro de frota (padrão novo) + veículo escolhido + const [empresaFilial, setEmpresaFilial] = useState({ empresaId: "", filialId: "" }) + const [vehicleId, setVehicleId] = useState("") + + // Datas e dados da viagem + const [isScheduled, setIsScheduled] = useState(false) + const [scheduledDate, setScheduledDate] = useState(undefined) + const [endDate, setEndDate] = useState(undefined) + const [driver, setDriver] = useState("") + const [passangers, setPassangers] = useState("") + const [destiny, setDestiny] = useState("") + + const { data: users, isLoading: isLoadingUsers } = api.user.listForChat.useQuery( + { search: searchQuery }, + { enabled: searchQuery.length > 2 }, + ) + + const { data: vehiclesData } = api.vehicle.getAll.useQuery({ limit: 100, availble: true }) + + // Veículos disponíveis filtrados por empresa/filial (padrão novo). + const vehicles = useMemo(() => { + return (vehiclesData?.items ?? []).filter((vehicle) => { + if (empresaFilial.filialId) return vehicle.filialId === empresaFilial.filialId + if (empresaFilial.empresaId) return vehicle.filial?.empresa.id === empresaFilial.empresaId + return true + }) + }, [vehiclesData, empresaFilial]) + + const createForUser = api.vehicleRent.createForUser.useMutation({ + onSuccess: () => { + toast.success("Agendamento criado com sucesso!") + onSuccess() + }, + onError: (error) => { + toast.error("Erro ao criar agendamento: " + error.message) + }, + }) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + + if (!selectedUser) { + toast.error("Selecione o usuário do agendamento.") + return + } + if (!vehicleId) { + toast.error("Selecione o veículo.") + return + } + if (isScheduled && (!scheduledDate || scheduledDate <= new Date())) { + toast.error("A data de agendamento deve ser posterior à data atual.") + return + } + if (!endDate || endDate <= new Date() || (scheduledDate && endDate <= scheduledDate)) { + toast.error("A data de devolução deve ser posterior à data atual ou à data de agendamento.") + return + } + if (driver.trim().length <= 2) { + toast.error("Informe o motorista (mínimo 3 caracteres).") + return + } + if (destiny.trim().length <= 2) { + toast.error("Informe o destino (mínimo 3 caracteres).") + return + } + + createForUser.mutate({ + userId: selectedUser.id, + vehicleId, + possibleEnd: endDate, + startDate: isScheduled ? scheduledDate : undefined, + driver, + destiny, + passangers: passangers || undefined, + }) + } + + return ( +
+ {/* Usuário alvo */} +
+ + {selectedUser ? ( +
+
+
+ {selectedUser.firstName} {selectedUser.lastName} +
+
{selectedUser.email}
+
+ +
+ ) : ( + <> + setSearchQuery(e.target.value)} + /> + {isLoadingUsers && ( +
+ + Buscando... +
+ )} + {users && users.length > 0 && ( +
+ {users.map((user) => ( + + ))} +
+ )} + + )} +
+ + {/* Veículo */} +
+ + + + {vehicles.length === 0 && ( +

Nenhum veículo disponível para o filtro selecionado.

+ )} +
+ + {/* Datas */} +
+ + +
+ + {isScheduled && ( +
+ + +
+ )} + +
+ + +
+ + {/* Dados da viagem */} +
+ + setDriver(e.target.value)} /> +
+
+ + setPassangers(e.target.value)} /> +
+
+ + setDestiny(e.target.value)} /> +
+ +
+ + +
+
+ ) +} diff --git a/src/components/admin/vehicles/vehicle-form.tsx b/src/components/admin/vehicles/vehicle-form.tsx index 2777d6cb..99068ea8 100644 --- a/src/components/admin/vehicles/vehicle-form.tsx +++ b/src/components/admin/vehicles/vehicle-form.tsx @@ -59,29 +59,19 @@ export function VehicleForm({ vehicle, onSuccess, onCancel }: VehicleFormProps) const filialId = watch("filialId") - // Filiais da empresa selecionada + // Empresa efetiva (fonte única da verdade): a escolha explícita do usuário tem + // precedência; senão, deriva da filial salva/selecionada. Assim, ao editar um + // veículo a empresa aparece preenchida assim que `filiaisData` carrega, sem + // depender de efeitos que poderiam apagar a filial salva. + const empresaFromFilial = filiaisData.find((f) => f.id === filialId)?.empresa.id ?? "" + const effectiveEmpresaId = empresaId !== "" ? empresaId : empresaFromFilial + + // Filiais da empresa efetiva const filiais = useMemo( - () => filiaisData.filter((f) => f.empresa.id === empresaId), - [filiaisData, empresaId], + () => filiaisData.filter((f) => f.empresa.id === effectiveEmpresaId), + [filiaisData, effectiveEmpresaId], ) - // Ao editar, pré-seleciona a empresa a partir da filial atual do veículo. - useEffect(() => { - if (vehicle?.filialId && filiaisData.length > 0) { - const current = filiaisData.find((f) => f.id === vehicle.filialId) - if (current) setEmpresaId(current.empresa.id) - } - }, [vehicle?.filialId, filiaisData]) - - // Ao trocar de empresa, limpa a filial que não pertence mais à empresa. - useEffect(() => { - if (!filialId) return - const allowed = new Set(filiais.map((f) => f.id)) - if (!allowed.has(filialId)) { - setValue("filialId", "") - } - }, [filiais, filialId, setValue]) - // Atualizar o campo imageUrl quando uma nova imagem for enviada useEffect(() => { if (uploadedImageUrl) { @@ -169,7 +159,7 @@ export function VehicleForm({ vehicle, onSuccess, onCancel }: VehicleFormProps)
setValue("filialId", value, { shouldValidate: true })} - disabled={!empresaId} + disabled={!effectiveEmpresaId} > - + {filiais.map((filial) => ( diff --git a/src/components/vehicles/rent-form.tsx b/src/components/vehicles/rent-form.tsx index 53efc360..9a55fc02 100644 --- a/src/components/vehicles/rent-form.tsx +++ b/src/components/vehicles/rent-form.tsx @@ -5,7 +5,7 @@ import type React from "react" import { useState, useEffect } from "react" import { useRouter } from "next/navigation" import Image from "next/image" -import { Car, Calendar, AlertCircle, Clock } from "lucide-react" +import { Car, Calendar, AlertCircle, Clock, CheckCircle2, XCircle, Loader2 } from "lucide-react" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" import { api } from "@/trpc/react" @@ -35,6 +35,8 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR const [destiny, setDestiny] = useState() const [endDate, setEndDate] = useState() const [scheduledDate, setScheduledDate] = useState(undefined) + // Referência de início para reserva imediata (capturada na montagem). + const [now] = useState(() => new Date()) // Hook para criação const createRent = api.vehicleRent.create.useMutation({ @@ -90,6 +92,27 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR }, }) + // Intervalo escolhido. Na criação, o início é explícito (default = agora, + // editável pelo usuário). Na edição, mantém o comportamento de "agendado". + const intervalStart = editMode + ? (isScheduled ? scheduledDate : existingRent?.startDate ?? undefined) + : scheduledDate + const hasInterval = Boolean(intervalStart && endDate) + + // Checa disponibilidade do veículo para o intervalo, sem criar nada. + const { data: availability, isFetching: checkingAvailability } = api.vehicleRent.checkAvailability.useQuery( + { + vehicleId: vehicle.id, + startDate: intervalStart!, + possibleEnd: endDate!, + excludeRentId: editMode ? existingRent?.id : undefined, + }, + { enabled: hasInterval }, + ) + + const isOccupied = availability?.available === false && availability?.reason === "conflict" + const isAvailable = hasInterval && availability?.available === true + // Carregar dados da reserva existente quando estiver no modo de edição useEffect(() => { if (editMode && existingRent) { @@ -108,36 +131,70 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR } }, [editMode, existingRent]) + // Criação: inicializa o início do intervalo com "agora" (editável pelo usuário). + useEffect(() => { + if (!editMode && !scheduledDate) { + setScheduledDate(now) + } + }, [editMode, scheduledDate, now]) + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() setIsSubmitting(true) - // Validar a data agendada - if (isScheduled && !scheduledDate) { - toast({ - title: "Data inválida", - description: "Por favor, selecione uma data e hora para o agendamento.", - variant: "destructive", - }) - setIsSubmitting(false) - return - } + // Início do intervalo usado para validar a devolução. + const startForValidation = editMode + ? (isScheduled ? scheduledDate : existingRent?.startDate ?? undefined) + : scheduledDate - // Verificar se a data é futura - if (isScheduled && scheduledDate && scheduledDate <= new Date()) { - toast({ - title: "Data inválida", - description: "A data de agendamento deve ser posterior à data atual.", - variant: "destructive", - }) - setIsSubmitting(false) - return + if (!editMode) { + // Criação: início é obrigatório e não pode ser no passado (dia anterior). + if (!scheduledDate) { + toast({ + title: "Data inválida", + description: "Selecione a data e hora de início.", + variant: "destructive", + }) + setIsSubmitting(false) + return + } + const startOfToday = new Date() + startOfToday.setHours(0, 0, 0, 0) + if (scheduledDate < startOfToday) { + toast({ + title: "Data inválida", + description: "A data de início não pode ser no passado.", + variant: "destructive", + }) + setIsSubmitting(false) + return + } + } else { + // Edição: mantém a regra de agendamento futuro existente. + if (isScheduled && !scheduledDate) { + toast({ + title: "Data inválida", + description: "Por favor, selecione uma data e hora para o agendamento.", + variant: "destructive", + }) + setIsSubmitting(false) + return + } + if (isScheduled && scheduledDate && scheduledDate <= new Date()) { + toast({ + title: "Data inválida", + description: "A data de agendamento deve ser posterior à data atual.", + variant: "destructive", + }) + setIsSubmitting(false) + return + } } - if (!endDate || endDate <= new Date() || (scheduledDate && endDate <= scheduledDate)) { + if (!endDate || endDate <= new Date() || (startForValidation && endDate <= startForValidation)) { toast({ title: "Data inválida", - description: "A data de devolução deve ser posterior à data atual ou à data de agendamento.", + description: "A data de devolução deve ser posterior ao início do intervalo.", variant: "destructive", }) setIsSubmitting(false) @@ -164,6 +221,16 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR return } + if (isOccupied) { + toast({ + title: "Veículo ocupado", + description: "Este veículo já está reservado no período selecionado. Escolha outro intervalo.", + variant: "destructive", + }) + setIsSubmitting(false) + return + } + if (editMode && existingRent) { // Modo de edição editRent.mutate({ @@ -175,21 +242,25 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR startDate: isScheduled ? scheduledDate : undefined, }) } else { - // Modo de criação + // Modo de criação: envia o início do intervalo escolhido pelo usuário. createRent.mutate({ vehicleId: vehicle.id, destiny: destiny, driver: driver, possibleEnd: endDate, passangers: passangers, - startDate: isScheduled ? scheduledDate : undefined, + startDate: scheduledDate, }) } } const handleCancel = () => { if (isModal) { - router.back() + if (onCloseModal) { + onCloseModal() + } else { + router.back() + } } else { router.push(`/cars/details/${vehicle.id}`) } @@ -235,36 +306,72 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR

Detalhes da reserva

-
- - -
- - {isScheduled ? ( -
-
- - -
-

O veículo será reservado para a data e hora selecionadas.

-
- ) : ( + {editMode ? ( <> -
- - Data de início: {new Date().toLocaleDateString()} -
-
- - Hora de início: {new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })} +
+ +
+ + {isScheduled ? ( +
+
+ + +
+

O veículo será reservado para a data e hora selecionadas.

+
+ ) : ( + <> +
+ + Data de início: {existingRent ? new Date(existingRent.startDate).toLocaleDateString() : new Date().toLocaleDateString()} +
+
+ + Hora de início: {existingRent ? new Date(existingRent.startDate).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : ""} +
+ + )} + ) : ( + // Criação: intervalo explícito (início → devolução). Início default = agora. +
+ + +

+ Escolha quando o veículo será retirado. Para uso imediato, mantenha a data/hora atual. +

+
)}
+ + {/* Indicador de disponibilidade para o intervalo escolhido */} + {hasInterval && ( +
+ {checkingAvailability ? ( + + + Verificando disponibilidade... + + ) : isOccupied ? ( + + + Ocupado neste período + + ) : isAvailable ? ( + + + Disponível neste período + + ) : null} +
+ )} +
setDriver(e.target.value)} /> @@ -287,7 +394,7 @@ export function RentForm({ vehicle, isModal = false, editMode = false, existingR - - ))} +
+ {dayReservations.slice(0, 3).map((reservation) => { + const start = utcToLocalDate(reservation.startDate) + const end = utcToLocalDate(reservation.possibleEnd) ?? start + const isStart = start ? isSameDay(day, start) : false + const isEnd = end ? isSameDay(day, end) : false + // Mostra o rótulo no início da reserva ou no início da semana + // (domingo), mantendo a faixa "limpa" nos dias de continuação. + const showLabel = isStart || day.getDay() === 0 + const color = colorForReservation(reservation.id) + + return ( + + ) + })} {dayReservations.length > 3 && (
diff --git a/src/schemas/vehicle-rent.schema.ts b/src/schemas/vehicle-rent.schema.ts index 2d98f7f9..d64b286d 100644 --- a/src/schemas/vehicle-rent.schema.ts +++ b/src/schemas/vehicle-rent.schema.ts @@ -9,6 +9,12 @@ export const createVehicleRentSchema = z.object({ destiny: z.string(), }) +// Agendamento criado por um admin em nome de outro usuário (painel admin). +// Reaproveita os mesmos campos da reserva e adiciona o usuário alvo. +export const createVehicleRentForUserSchema = createVehicleRentSchema.extend({ + userId: z.string().min(1, "Selecione o usuário"), +}) + export const updateVehicleRentSchema = z.object({ endDate: z.date().optional(), finished: z.boolean().optional(), @@ -54,6 +60,7 @@ export const finishRentWithoutUsageSchema = z.object({ }) export type CreateVehicleRentInput = z.infer +export type CreateVehicleRentForUserInput = z.infer export type UpdateVehicleRentInput = z.infer export type EditVehicleRentInput = z.infer export type FinishRentInput = z.infer diff --git a/src/server/api/routers/vehicle-rent.ts b/src/server/api/routers/vehicle-rent.ts index ce5299eb..fafdd647 100644 --- a/src/server/api/routers/vehicle-rent.ts +++ b/src/server/api/routers/vehicle-rent.ts @@ -1,14 +1,103 @@ import "server-only"; import { TRPCError } from "@trpc/server" import { z } from "zod" +import type { PrismaClient } from "@prisma/client" import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc" -import { createVehicleRentSchema, finishRentSchema, vehicleRentIdSchema, editVehicleRentSchema, finishRentWithoutUsageSchema } from "@/schemas/vehicle-rent.schema" +import { createVehicleRentSchema, createVehicleRentForUserSchema, finishRentSchema, vehicleRentIdSchema, editVehicleRentSchema, finishRentWithoutUsageSchema } from "@/schemas/vehicle-rent.schema" import { sendEmail } from "@/lib/mail/email-utils" import { mockEmailReservaCarro } from "@/lib/mail/html-mock" -import { canLocateCars } from "@/lib/access-control" +import { canLocateCars, hasAdminAccess } from "@/lib/access-control" import type { RolesConfig } from "@/types/role-config" +// Campos comuns de criação de reserva (usados pelo fluxo self-service e pelo +// agendamento manual feito por um admin em nome de outro usuário). +type CreateRentFields = { + vehicleId: string + startDate?: Date + possibleEnd: Date + driver: string + destiny: string + passangers?: string | null +} + +// Deslocamento de -3h aplicado às datas de reserva (compat. com o armazenamento +// existente). Centralizado para que a criação e a checagem de disponibilidade +// usem exatamente a mesma referência de tempo. +function shiftRentDate(date: Date): Date { + const d = new Date(date) + d.setHours(d.getHours() - 3) + return d +} + +/** + * Cria uma reserva para `userId`. Concentra a validação de campos, o ajuste de + * fuso (-3h) e a checagem de conflito dentro de uma transação atômica. As + * verificações de permissão ficam em cada procedure (self-service x admin). + */ +async function createRentForUser(db: PrismaClient, userId: string, input: CreateRentFields) { + const { vehicleId, startDate, possibleEnd, driver, destiny, passangers } = input + + const newStartDate = shiftRentDate(startDate ?? new Date()) + const newPossibleEnd = shiftRentDate(possibleEnd ?? new Date()) + + if (!possibleEnd) { + throw new TRPCError({ code: "BAD_REQUEST", message: "A data de término prevista é obrigatória." }) + } + + if (!driver || driver.trim().length <= 2) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Nome do motorista é obrigatório e deve ter pelo menos 3 caracteres." }) + } + + if (!destiny || destiny.trim().length <= 2) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Destino é obrigatório e deve ter pelo menos 3 caracteres." }) + } + + // Transação para garantir que a checagem de disponibilidade e a criação sejam atômicas. + return db.$transaction(async (tx) => { + const vehicle = await tx.vehicle.findUnique({ + where: { id: vehicleId }, + select: { id: true, kilometers: true }, + }) + + if (!vehicle) { + throw new TRPCError({ code: "NOT_FOUND", message: "Veículo não encontrado" }) + } + + const conflictingRent = await tx.vehicleRent.findFirst({ + where: { + vehicleId: vehicleId, + finished: false, + AND: [ + { startDate: { lt: newPossibleEnd } }, + { possibleEnd: { gt: newStartDate } }, + ], + }, + }) + + if (conflictingRent) { + throw new TRPCError({ + code: "CONFLICT", + message: "Este veículo não está mais disponível para reserva no período solicitado. Por favor, tente outro horário.", + }) + } + + return tx.vehicleRent.create({ + data: { + userId, + startDate: newStartDate, + possibleEnd: newPossibleEnd, + vehicleId: vehicleId, + initialKm: BigInt(vehicle.kilometers.toString()), + driver: driver || "", + destiny: destiny || "", + passangers: passangers ?? null, + }, + include: { vehicle: true }, + }) + }) +} + export const vehicleRentRouter = createTRPCRouter({ getAll: protectedProcedure @@ -304,6 +393,41 @@ export const vehicleRentRouter = createTRPCRouter({ return reservations }), + // Verifica se um veículo está disponível para um intervalo (sem criar nada). + // Usado pelo catálogo de reserva para apontar "Disponível" / "Ocupado". + checkAvailability: protectedProcedure + .input( + z.object({ + vehicleId: z.string(), + startDate: z.date(), + possibleEnd: z.date(), + excludeRentId: z.string().optional(), + }), + ) + .query(async ({ ctx, input }) => { + const start = shiftRentDate(input.startDate) + const end = shiftRentDate(input.possibleEnd) + + if (end <= start) { + return { available: false, reason: "invalid_interval" as const } + } + + const conflict = await ctx.db.vehicleRent.findFirst({ + where: { + vehicleId: input.vehicleId, + finished: false, + AND: [ + { startDate: { lt: end } }, + { possibleEnd: { gt: start } }, + ], + ...(input.excludeRentId ? { id: { not: input.excludeRentId } } : {}), + }, + select: { id: true }, + }) + + return { available: !conflict, reason: conflict ? ("conflict" as const) : null } + }), + create: protectedProcedure.input(createVehicleRentSchema).mutation(async ({ ctx, input }) => { // Verificar se o usuário tem permissão para fazer agendamentos de carros const db_user = await ctx.db.user.findUnique({ @@ -318,105 +442,39 @@ export const vehicleRentRouter = createTRPCRouter({ }) } - const userId = ctx.auth.userId - const { vehicleId, startDate, possibleEnd, driver, destiny, passangers } = input - - console.log("Input completo:", input) - console.log("Campos extraídos:", { vehicleId, startDate, possibleEnd, driver, destiny, passangers }) + return createRentForUser(ctx.db, ctx.auth.userId, input) + }), - const newStartDate = new Date(startDate ?? new Date()).setHours(new Date(startDate ?? new Date()).getHours() - 3); - const newPossibleEnd = new Date(possibleEnd ?? new Date()).setHours(new Date(possibleEnd ?? new Date()).getHours() - 3); + // Agendamento manual feito por um admin em nome de outro usuário (painel admin). + // Gateado pela permissão da rota /admin/vehicles (ou sudo). + createForUser: protectedProcedure.input(createVehicleRentForUserSchema).mutation(async ({ ctx, input }) => { + const db_user = await ctx.db.user.findUnique({ + where: { id: ctx.auth.userId }, + select: { role_config: true }, + }) - if (!possibleEnd) { + if (!hasAdminAccess(db_user?.role_config as RolesConfig | null, "/admin/vehicles")) { throw new TRPCError({ - code: "BAD_REQUEST", - message: "A data de término prevista é obrigatória.", + code: "FORBIDDEN", + message: "Você não tem permissão para criar agendamentos para outros usuários", }) } - if (!driver || driver.trim().length <= 2) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: "Nome do motorista é obrigatório e deve ter pelo menos 3 caracteres.", - }) - } + const { userId, ...rentInput } = input + + const targetUser = await ctx.db.user.findUnique({ + where: { id: userId }, + select: { id: true }, + }) - if (!destiny || destiny.trim().length <= 2) { + if (!targetUser) { throw new TRPCError({ - code: "BAD_REQUEST", - message: "Destino é obrigatório e deve ter pelo menos 3 caracteres.", + code: "NOT_FOUND", + message: "Usuário selecionado não encontrado", }) } - // Usar uma transação para garantir que a verificação de disponibilidade e a criação da reserva sejam atômicas - return ctx.db.$transaction(async (tx) => { - // 1. Verificar se o veículo existe - const vehicle = await tx.vehicle.findUnique({ - where: { id: vehicleId }, - select: { id: true, kilometers: true }, - }) - - console.log("Veículo encontrado:", vehicle) - - if (!vehicle) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Veículo não encontrado", - }) - } - - // 2. Verificar novamente a disponibilidade dentro da transação para evitar race conditions - const conflictingRent = await tx.vehicleRent.findFirst({ - where: { - vehicleId: vehicleId, - finished: false, - AND: [ - { - startDate: { - lt: new Date(newPossibleEnd), - }, - }, - { - possibleEnd: { - gt: new Date(newStartDate), - }, - }, - ], - }, - }) - - if (conflictingRent) { - throw new TRPCError({ - code: "CONFLICT", - message: "Este veículo não está mais disponível para reserva no período solicitado. Por favor, tente outro horário.", - }) - } - - // 3. Criar a reserva - const rentData = { - userId, - startDate: new Date(newStartDate), - possibleEnd: new Date(newPossibleEnd), - vehicleId: vehicleId, - initialKm: BigInt(vehicle.kilometers.toString()), - driver: driver || "", - destiny: destiny || "", - passangers: passangers ?? null, - } - - console.log("Dados a serem salvos no banco:", rentData) - - const rent = await tx.vehicleRent.create({ - data: rentData, - include: { - vehicle: true, - }, - }) - - console.log("Reserva criada com sucesso:", rent.id) - - return rent - }) + return createRentForUser(ctx.db, userId, rentInput) }), finish: protectedProcedure.input(finishRentSchema).mutation(async ({ ctx, input }) => {