diff --git a/package.json b/package.json index e62254d4..fbb72df3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "elo", - "version": "1.46.0", + "version": "1.47.0", "private": true, "type": "module", "scripts": { diff --git a/prisma/migrations/20260617120000_vehicle_filial_fk/migration.sql b/prisma/migrations/20260617120000_vehicle_filial_fk/migration.sql new file mode 100644 index 00000000..80995891 --- /dev/null +++ b/prisma/migrations/20260617120000_vehicle_filial_fk/migration.sql @@ -0,0 +1,8 @@ +-- AlterTable +ALTER TABLE "vehicles" ADD COLUMN "filialId" TEXT; + +-- CreateIndex +CREATE INDEX "vehicles_filialId_idx" ON "vehicles"("filialId"); + +-- AddForeignKey +ALTER TABLE "vehicles" ADD CONSTRAINT "vehicles_filialId_fkey" FOREIGN KEY ("filialId") REFERENCES "filiais"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ed641af2..3064efb1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -256,11 +256,16 @@ model Vehicle { model String plate String @unique imageUrl String + // Enum legado mantido em sincronia com `filial.empresa.enterprise` para + // compatibilidade com relatórios/exibições antigas. A fonte da verdade é a filial. enterprise Enterprise + filialId String? + filial Filial? @relation(fields: [filialId], references: [id], onDelete: SetNull) kilometers BigInt availble Boolean @default(true) rents VehicleRent[] + @@index([filialId]) @@map("vehicles") } @@ -619,6 +624,7 @@ model Filial { empresa Empresa @relation(fields: [empresaId], references: [id], onDelete: Restrict) users User[] restaurants Restaurant[] + vehicles Vehicle[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/src/app/(authenticated)/admin/vehicles/page.tsx b/src/app/(authenticated)/admin/vehicles/page.tsx index de10182e..b2c148c2 100644 --- a/src/app/(authenticated)/admin/vehicles/page.tsx +++ b/src/app/(authenticated)/admin/vehicles/page.tsx @@ -50,11 +50,17 @@ export default function VehiclesAdminClient() { } } - const filteredVehicles = vehicles?.items?.filter(vehicle => - vehicle.model.toLowerCase().includes(searchTerm.toLowerCase()) || - vehicle.plate.toLowerCase().includes(searchTerm.toLowerCase()) || - vehicle.enterprise.toLowerCase().includes(searchTerm.toLowerCase()) - ) ?? [] + const filteredVehicles = vehicles?.items?.filter(vehicle => { + const term = searchTerm.toLowerCase() + return ( + vehicle.model.toLowerCase().includes(term) || + vehicle.plate.toLowerCase().includes(term) || + vehicle.enterprise.toLowerCase().includes(term) || + (vehicle.filial?.name.toLowerCase().includes(term) ?? false) || + (vehicle.filial?.code.toLowerCase().includes(term) ?? false) || + (vehicle.filial?.empresa.name.toLowerCase().includes(term) ?? false) + ) + }) ?? [] if (isLoading) { return ( @@ -110,9 +116,9 @@ export default function VehiclesAdminClient() { onSuccess={() => { setIsCreateDialogOpen(false) void refetch() - } } onCancel={function (): void { - throw new Error("Function not implemented.") - } } /> + }} + onCancel={() => setIsCreateDialogOpen(false)} + /> @@ -199,7 +205,11 @@ export default function VehiclesAdminClient() {
Empresa: - {vehicle.enterprise} + {vehicle.filial?.empresa.name ?? vehicle.enterprise} +
+
+ Filial: + {vehicle.filial ? `${vehicle.filial.name} (${vehicle.filial.code})` : "—"}
Quilometragem: @@ -232,10 +242,11 @@ export default function VehiclesAdminClient() { setIsEditDialogOpen(false) setSelectedVehicle(null) void refetch() - }} onCancel={function (): void { - throw new Error("Function not implemented.") - } } - + }} + onCancel={() => { + setIsEditDialogOpen(false) + setSelectedVehicle(null) + }} /> diff --git a/src/app/(authenticated)/cars/details/page.tsx b/src/app/(authenticated)/cars/details/page.tsx index c3b17dae..3140d19c 100644 --- a/src/app/(authenticated)/cars/details/page.tsx +++ b/src/app/(authenticated)/cars/details/page.tsx @@ -1,12 +1,11 @@ "use client" import { useState, Suspense } from "react" -import { useSearchParams } from "next/navigation" -import type { Enterprise, Vehicle } from "@prisma/client" +import type { Vehicle } from "@prisma/client" import { api } from "@/trpc/react" import { VehicleCard } from "@/components/vehicles/vehicle-card" -import { EnterpriseFilter } from "@/components/ui/enterprise-filter" +import { EmpresaFilialFilter, type EmpresaFilialValue } from "@/components/ui/empresa-filial-filter" import { VehicleCardSkeleton } from "@/components/vehicles/vehicle-card-skeleton" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -20,8 +19,8 @@ import { Clock, Filter } from "lucide-react" type VehicleWithNumberKm = Omit & { kilometers: number } function VehiclesPageContent() { - const searchParams = useSearchParams() - const enterprise = searchParams.get("enterprise") as Enterprise | null + // Filtro no padrão novo (empresa → filial) + const [empresaFilial, setEmpresaFilial] = useState({ empresaId: "", filialId: "" }) // Estados para filtros de data e horário específicos const [selectedDate, setSelectedDate] = useState("") @@ -45,17 +44,17 @@ function VehiclesPageContent() { }, ) - const vehicles: VehicleWithNumberKm[] = - specificFilterData?.items?.map( - (vehicle): VehicleWithNumberKm => ({ - ...vehicle, - kilometers: Number(vehicle.kilometers), - }), - ) ?? [] - - const filteredVehicles = enterprise - ? vehicles?.filter((vehicle) => vehicle.enterprise === enterprise) - : vehicles + // Filtra por filial/empresa (padrão novo) antes de mapear quilometragem. + const filteredVehicles: VehicleWithNumberKm[] = (specificFilterData?.items ?? []) + .filter((vehicle) => { + if (empresaFilial.filialId) return vehicle.filialId === empresaFilial.filialId + if (empresaFilial.empresaId) return vehicle.filial?.empresa.id === empresaFilial.empresaId + return true + }) + .map((vehicle) => ({ + ...vehicle, + kilometers: Number(vehicle.kilometers), + })) // Funções para controlar os filtros const applyFilters = () => { @@ -131,7 +130,7 @@ function VehiclesPageContent() {
- +
{isLoading && ( diff --git a/src/app/(authenticated)/cars/page.tsx b/src/app/(authenticated)/cars/page.tsx index c58abe27..83d5cd76 100644 --- a/src/app/(authenticated)/cars/page.tsx +++ b/src/app/(authenticated)/cars/page.tsx @@ -16,6 +16,7 @@ import { FinishRentButton } from "@/components/vehicles/finish-rent-button" import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, DialogFooter } from "@/components/ui/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" @@ -27,6 +28,8 @@ export default function DashboardPage() { const [selectedDate, setSelectedDate] = useState("") const [selectedTime, setSelectedTime] = useState("") const [filterApplied, setFilterApplied] = useState(false) + // Filtro no padrão novo (empresa → filial) + const [empresaFilial, setEmpresaFilial] = useState({ empresaId: "", filialId: "" }) // Buscar dados do usuário e reservas ativas const { data: userData } = api.user.me.useQuery() @@ -48,6 +51,13 @@ export default function DashboardPage() { // 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) => { + 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 const openEditModal = (rent: VehicleRent & { vehicle: Vehicle }) => { setSelectedRent(rent) @@ -158,18 +168,21 @@ export default function DashboardPage() { - Veículos Disponíveis ({availableVehicles?.items?.length ?? 0}) + Veículos Disponíveis ({filteredVehicles.length}) +
+ +
{isLoadingVehicles ? (

Carregando veículos disponíveis...

- ) : availableVehicles?.items?.length ? ( + ) : filteredVehicles.length ? (
- {availableVehicles.items.map((vehicle) => ( + {filteredVehicles.map((vehicle) => (
void @@ -34,6 +26,11 @@ interface VehicleFormProps { export function VehicleForm({ vehicle, onSuccess, onCancel }: VehicleFormProps) { const [isLoading, setIsLoading] = useState(false) const [uploadedImageUrl, setUploadedImageUrl] = useState("") + // Estado local da empresa para a cascata empresa → filial (padrão novo). + const [empresaId, setEmpresaId] = useState("") + + const { data: empresas = [] } = api.empresas.list.useQuery() + const { data: filiaisData = [] } = api.filiais.list.useQuery() const { register, @@ -47,19 +44,44 @@ export function VehicleForm({ vehicle, onSuccess, onCancel }: VehicleFormProps) model: vehicle.model, plate: vehicle.plate, imageUrl: vehicle.imageUrl || "", - enterprise: vehicle.enterprise as "NA" | "Box" | "RHenz" | "Cristallux" | "Box_Filial", + filialId: vehicle.filialId ?? "", kilometers: Number(vehicle.kilometers), availble: vehicle.availble, } : { model: "", plate: "", imageUrl: "", - enterprise: "NA" as const, + filialId: "", kilometers: 0, availble: true, }, }) + const filialId = watch("filialId") + + // Filiais da empresa selecionada + const filiais = useMemo( + () => filiaisData.filter((f) => f.empresa.id === empresaId), + [filiaisData, empresaId], + ) + + // 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) { @@ -97,8 +119,6 @@ export function VehicleForm({ vehicle, onSuccess, onCancel }: VehicleFormProps) } } - const selectedEnterprise = watch("enterprise") - return (
@@ -147,27 +167,52 @@ export function VehicleForm({ vehicle, onSuccess, onCancel }: VehicleFormProps)
- + - {errors.enterprise && ( -

{errors.enterprise.message}

+
+ +
+ + + {errors.filialId && ( +

{errors.filialId.message}

)}
+
+
void +} + +/** + * Filtro no padrão novo (empresa → filial) em cascata. Controlado pelo pai: + * recebe `value` e emite `onChange`. Ao trocar a empresa, a filial é limpa. + */ +export function EmpresaFilialFilter({ value, onChange }: EmpresaFilialFilterProps) { + const { data: empresas = [] } = api.empresas.list.useQuery() + const { data: filiaisData = [] } = api.filiais.list.useQuery() + + const filiais = useMemo( + () => filiaisData.filter((f) => f.empresa.id === value.empresaId), + [filiaisData, value.empresaId], + ) + + const hasFilter = Boolean(value.empresaId || value.filialId) + + return ( +
+ + + + + {hasFilter && ( + + )} +
+ ) +} diff --git a/src/schemas/vehicle.schema.ts b/src/schemas/vehicle.schema.ts index 1bab95da..e42fded5 100644 --- a/src/schemas/vehicle.schema.ts +++ b/src/schemas/vehicle.schema.ts @@ -6,7 +6,9 @@ export const createVehicleSchema = z.object({ model: z.string().min(1, "Modelo é obrigatório"), plate: z.string().min(1, "Placa é obrigatória").max(7, "Placa deve ter no máximo 7 caracteres"), imageUrl: z.string().url("URL da imagem inválida"), - enterprise: EnterpriseEnum, + // Vínculo no padrão novo: o veículo pertence a uma Filial e a Empresa (enum + // legado `enterprise`) é derivada de `filial.empresa` no servidor. + filialId: z.string().min(1, "Selecione a filial"), kilometers: z.number().or(z.string().regex(/^\d+$/).transform(Number)), availble: z.boolean().optional().default(true), }) diff --git a/src/server/api/routers/vehicle.ts b/src/server/api/routers/vehicle.ts index 851e9682..fe9c2cec 100644 --- a/src/server/api/routers/vehicle.ts +++ b/src/server/api/routers/vehicle.ts @@ -4,6 +4,13 @@ import { z } from "zod" import { createTRPCRouter, protectedProcedure } from "@/server/api/trpc" import { createVehicleSchema, updateVehicleSchema, vehicleIdSchema } from "@/schemas/vehicle.schema" +import { resolveEnterpriseFromFilial } from "@/server/validators/filial-enterprise" + +// Inclui a filial (com a empresa dona) nas leituras de veículos para que a UI +// possa filtrar/exibir no padrão novo (empresa + filial). +const filialInclude = { + filial: { include: { empresa: { select: { id: true, name: true, enterprise: true } } } }, +} as const export const vehicleRouter = createTRPCRouter({ getAvailable: protectedProcedure @@ -67,6 +74,7 @@ export const vehicleRouter = createTRPCRouter({ orderBy: { model: "asc", }, + include: filialInclude, }) return availableVehicles.map((v) => ({ @@ -81,6 +89,8 @@ export const vehicleRouter = createTRPCRouter({ limit: z.number().min(1).max(100).nullish(), cursor: z.string().nullish(), enterprise: z.enum(["NA", "Box", "RHenz", "Cristallux"]).optional(), + empresaId: z.string().optional(), + filialId: z.string().optional(), availble: z.boolean().optional(), checkDate: z.string().optional(), checkTime: z.string().optional(), @@ -88,20 +98,24 @@ export const vehicleRouter = createTRPCRouter({ ) .query(async ({ ctx, input }) => { const limit = input.limit ?? 50 - const { cursor, enterprise, availble, checkDate, checkTime } = input + const { cursor, enterprise, empresaId, filialId, availble, checkDate, checkTime } = input - // Buscar veículos baseados nos filtros básicos + // Buscar veículos baseados nos filtros básicos. Filtro no padrão novo + // (empresa/filial) tem precedência; `enterprise` é mantido por compat. let vehicles = await ctx.db.vehicle.findMany({ take: limit + 1, where: { enterprise: enterprise, availble: availble, + ...(filialId ? { filialId } : {}), + ...(empresaId ? { filial: { empresaId } } : {}), }, cursor: cursor ? { id: cursor } : undefined, orderBy: { model: "asc", }, include: { + ...filialInclude, rents: { where: { finished: false, @@ -163,6 +177,7 @@ export const vehicleRouter = createTRPCRouter({ const vehicle = await ctx.db.vehicle.findUnique({ where: { id: input.id }, include: { + ...filialInclude, rents: { include: { user: { @@ -206,12 +221,16 @@ export const vehicleRouter = createTRPCRouter({ }) } + // A empresa (enum legado) é derivada da filial selecionada. + const enterprise = await resolveEnterpriseFromFilial(ctx.db, input.filialId) + return ctx.db.vehicle.create({ data: { model: input.model, plate: input.plate, imageUrl: input.imageUrl, - enterprise: input.enterprise, + filialId: input.filialId, + enterprise, kilometers: BigInt(input.kilometers.toString()), availble: input.availble, }, @@ -257,11 +276,17 @@ export const vehicleRouter = createTRPCRouter({ // Converter kilometers para BigInt se fornecido const kilometers = data.kilometers ? BigInt(data.kilometers.toString()) : undefined + // Se a filial mudou, recalcular a empresa (enum legado) a partir dela. + const enterprise = data.filialId + ? await resolveEnterpriseFromFilial(ctx.db, data.filialId) + : undefined + return ctx.db.vehicle.update({ where: { id }, data: { ...data, kilometers, + ...(enterprise ? { enterprise } : {}), }, }) }),