Add vehicle management and improve user authentication and management

Introduce a new section for vehicle management, enhance user authentication with bcrypt, and implement CRUD operations for users.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/GNrPM6a
This commit is contained in:
marco370 2025-10-16 17:41:22 +00:00
parent 09ff76e02d
commit 0203c9694d
11 changed files with 1801 additions and 120 deletions

View File

@ -9,6 +9,7 @@ import { AppSidebar } from "@/components/app-sidebar";
import { useAuth } from "@/hooks/useAuth";
import NotFound from "@/pages/not-found";
import Landing from "@/pages/landing";
import Login from "@/pages/login";
import Dashboard from "@/pages/dashboard";
import Guards from "@/pages/guards";
import Sites from "@/pages/sites";
@ -17,12 +18,14 @@ import Reports from "@/pages/reports";
import Notifications from "@/pages/notifications";
import Users from "@/pages/users";
import Planning from "@/pages/planning";
import Vehicles from "@/pages/vehicles";
function Router() {
const { isAuthenticated, isLoading } = useAuth();
return (
<Switch>
<Route path="/login" component={Login} />
{isLoading || !isAuthenticated ? (
<Route path="/" component={Landing} />
) : (
@ -30,6 +33,7 @@ function Router() {
<Route path="/" component={Dashboard} />
<Route path="/guards" component={Guards} />
<Route path="/sites" component={Sites} />
<Route path="/vehicles" component={Vehicles} />
<Route path="/shifts" component={Shifts} />
<Route path="/planning" component={Planning} />
<Route path="/reports" component={Reports} />

View File

@ -9,6 +9,7 @@ import {
LogOut,
UserCog,
ClipboardList,
Car,
} from "lucide-react";
import { Link, useLocation } from "wouter";
import {
@ -59,6 +60,12 @@ const menuItems = [
icon: MapPin,
roles: ["admin", "coordinator", "client"],
},
{
title: "Parco Automezzi",
url: "/vehicles",
icon: Car,
roles: ["admin", "coordinator"],
},
{
title: "Report",
url: "/reports",

158
client/src/pages/login.tsx Normal file
View File

@ -0,0 +1,158 @@
import { useState } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useMutation } from "@tanstack/react-query";
import { apiRequest } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { useToast } from "@/hooks/use-toast";
import { Shield, Loader2 } from "lucide-react";
const loginSchema = z.object({
email: z.string().email("Email non valida"),
password: z.string().min(3, "Password troppo corta"),
});
type LoginForm = z.infer<typeof loginSchema>;
export default function Login() {
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const form = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
defaultValues: {
email: "",
password: "",
},
});
const loginMutation = useMutation({
mutationFn: async (data: LoginForm) => {
const formData = new URLSearchParams();
formData.append("email", data.email);
formData.append("password", data.password);
const response = await fetch("/api/local-login", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: formData.toString(),
credentials: "include",
});
if (!response.ok) {
throw new Error("Credenziali non valide");
}
return response.json();
},
onSuccess: () => {
toast({
title: "Accesso effettuato",
description: "Benvenuto nel sistema VigilanzaTurni",
});
window.location.href = "/";
},
onError: () => {
toast({
title: "Errore di accesso",
description: "Email o password non corretti",
variant: "destructive",
});
},
onSettled: () => {
setIsLoading(false);
},
});
const onSubmit = (data: LoginForm) => {
setIsLoading(true);
loginMutation.mutate(data);
};
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-3 text-center">
<div className="mx-auto w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center">
<Shield className="h-8 w-8 text-primary" />
</div>
<CardTitle className="text-2xl font-bold">VigilanzaTurni</CardTitle>
<CardDescription>
Accedi al sistema di gestione turni
</CardDescription>
</CardHeader>
<CardContent>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input
type="email"
placeholder="admin@vt.alfacom.it"
data-testid="input-email"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input
type="password"
placeholder="••••••••"
data-testid="input-password"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
className="w-full"
disabled={isLoading}
data-testid="button-login"
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Accesso in corso...
</>
) : (
"Accedi"
)}
</Button>
</form>
</Form>
<div className="mt-6 p-4 bg-muted rounded-md text-sm text-muted-foreground">
<p className="font-semibold mb-1">Credenziali di default:</p>
<p>Email: admin@vt.alfacom.it</p>
<p>Password: admin123</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -1,7 +1,11 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { useAuth } from "@/hooks/useAuth";
import { type User } from "@shared/schema";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertUserFormSchema, updateUserFormSchema, type User } from "@shared/schema";
import { z } from "zod";
import {
Card,
CardContent,
@ -17,6 +21,24 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
@ -24,11 +46,21 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Badge } from "@/components/ui/badge";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useToast } from "@/hooks/use-toast";
import { Shield, UserCog, Users as UsersIcon, UserCheck } from "lucide-react";
import { Shield, UserCog, Users as UsersIcon, UserCheck, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
const roleIcons = {
admin: Shield,
@ -51,35 +83,128 @@ const roleColors = {
client: "bg-orange-500/10 text-orange-500 border-orange-500/20",
};
type CreateUserForm = z.infer<typeof insertUserFormSchema>;
type UpdateUserForm = z.infer<typeof updateUserFormSchema>;
export default function Users() {
const { user: currentUser } = useAuth();
const { toast } = useToast();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const { data: users, isLoading } = useQuery<User[]>({
queryKey: ["/api/users"],
});
const updateRoleMutation = useMutation({
mutationFn: async ({ userId, role }: { userId: string; role: string }) => {
return apiRequest("PATCH", `/api/users/${userId}`, { role });
const createForm = useForm<CreateUserForm>({
resolver: zodResolver(insertUserFormSchema),
defaultValues: {
email: "",
firstName: "",
lastName: "",
password: "",
role: "guard",
},
});
const editForm = useForm<UpdateUserForm>({
resolver: zodResolver(updateUserFormSchema.required({ role: true })),
defaultValues: {
email: "",
firstName: "",
lastName: "",
password: "",
role: "guard",
},
});
const createUserMutation = useMutation({
mutationFn: async (data: CreateUserForm) => {
return apiRequest("POST", "/api/users", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
toast({
title: "Utente creato",
description: "L'utente è stato creato con successo.",
});
setCreateDialogOpen(false);
createForm.reset();
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile creare l'utente.",
variant: "destructive",
});
},
});
const updateUserMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: UpdateUserForm }) => {
return apiRequest("PUT", `/api/users/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
toast({
title: "Ruolo aggiornato",
description: "Il ruolo dell'utente è stato modificato con successo.",
title: "Utente aggiornato",
description: "L'utente è stato modificato con successo.",
});
setEditDialogOpen(false);
setSelectedUser(null);
editForm.reset();
},
onError: () => {
onError: (error: any) => {
toast({
title: "Errore",
description: "Impossibile aggiornare il ruolo dell'utente.",
description: error.message || "Impossibile aggiornare l'utente.",
variant: "destructive",
});
},
});
const deleteUserMutation = useMutation({
mutationFn: async (id: string) => {
return apiRequest("DELETE", `/api/users/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
toast({
title: "Utente eliminato",
description: "L'utente è stato eliminato con successo.",
});
setDeleteDialogOpen(false);
setSelectedUser(null);
},
onError: () => {
toast({
title: "Errore",
description: "Impossibile eliminare l'utente.",
variant: "destructive",
});
},
});
const handleEdit = (user: User) => {
setSelectedUser(user);
editForm.reset({
email: user.email || "",
firstName: user.firstName || "",
lastName: user.lastName || "",
password: "",
role: user.role,
});
setEditDialogOpen(true);
};
const handleDelete = (user: User) => {
setSelectedUser(user);
setDeleteDialogOpen(true);
};
if (isLoading) {
return (
<div className="space-y-6">
@ -111,13 +236,19 @@ export default function Users() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold" data-testid="text-page-title">
Gestione Utenti
</h1>
<p className="text-muted-foreground">
Gestisci utenti e permessi del sistema VigilanzaTurni
</p>
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold" data-testid="text-page-title">
Gestione Utenti
</h1>
<p className="text-muted-foreground">
Gestisci utenti e permessi del sistema VigilanzaTurni
</p>
</div>
<Button onClick={() => setCreateDialogOpen(true)} data-testid="button-add-user">
<Plus className="h-4 w-4 mr-2" />
Aggiungi Utente
</Button>
</div>
<Card>
@ -133,8 +264,8 @@ export default function Users() {
<TableRow>
<TableHead>Utente</TableHead>
<TableHead>Email</TableHead>
<TableHead>Ruolo Attuale</TableHead>
<TableHead>Modifica Ruolo</TableHead>
<TableHead>Ruolo</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@ -181,45 +312,26 @@ export default function Users() {
{roleLabels[user.role]}
</Badge>
</TableCell>
<TableCell>
<Select
value={user.role}
onValueChange={(role) =>
updateRoleMutation.mutate({ userId: user.id, role })
}
disabled={isCurrentUser || updateRoleMutation.isPending}
data-testid={`select-role-${user.id}`}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="admin" data-testid={`option-admin-${user.id}`}>
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Admin
</div>
</SelectItem>
<SelectItem value="coordinator" data-testid={`option-coordinator-${user.id}`}>
<div className="flex items-center gap-2">
<UserCog className="h-4 w-4" />
Coordinatore
</div>
</SelectItem>
<SelectItem value="guard" data-testid={`option-guard-${user.id}`}>
<div className="flex items-center gap-2">
<UsersIcon className="h-4 w-4" />
Guardia
</div>
</SelectItem>
<SelectItem value="client" data-testid={`option-client-${user.id}`}>
<div className="flex items-center gap-2">
<UserCheck className="h-4 w-4" />
Cliente
</div>
</SelectItem>
</SelectContent>
</Select>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(user)}
data-testid={`button-edit-${user.id}`}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(user)}
disabled={isCurrentUser}
data-testid={`button-delete-${user.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
@ -234,6 +346,292 @@ export default function Users() {
)}
</CardContent>
</Card>
{/* Create User Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent data-testid="dialog-create-user">
<DialogHeader>
<DialogTitle>Crea Nuovo Utente</DialogTitle>
<DialogDescription>
Inserisci i dati del nuovo utente. La password deve essere di almeno 6 caratteri.
</DialogDescription>
</DialogHeader>
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit((data) => createUserMutation.mutate(data))} className="space-y-4">
<FormField
control={createForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="utente@esempio.it" data-testid="input-create-email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createForm.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input placeholder="Mario" data-testid="input-create-firstname" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Cognome</FormLabel>
<FormControl>
<Input placeholder="Rossi" data-testid="input-create-lastname" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={createForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<Input type="password" placeholder="••••••••" data-testid="input-create-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Ruolo</FormLabel>
<Select onValueChange={field.onChange} value={field.value} data-testid="select-create-role">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Admin
</div>
</SelectItem>
<SelectItem value="coordinator">
<div className="flex items-center gap-2">
<UserCog className="h-4 w-4" />
Coordinatore
</div>
</SelectItem>
<SelectItem value="guard">
<div className="flex items-center gap-2">
<UsersIcon className="h-4 w-4" />
Guardia
</div>
</SelectItem>
<SelectItem value="client">
<div className="flex items-center gap-2">
<UserCheck className="h-4 w-4" />
Cliente
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} data-testid="button-create-cancel">
Annulla
</Button>
<Button type="submit" disabled={createUserMutation.isPending} data-testid="button-create-submit">
{createUserMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creazione...
</>
) : (
"Crea Utente"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Edit User Dialog */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent data-testid="dialog-edit-user">
<DialogHeader>
<DialogTitle>Modifica Utente</DialogTitle>
<DialogDescription>
Modifica i dati dell'utente. Lascia la password vuota per mantenerla invariata.
</DialogDescription>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit((data) => selectedUser && updateUserMutation.mutate({ id: selectedUser.id, data }))} className="space-y-4">
<FormField
control={editForm.control}
name="email"
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input type="email" placeholder="utente@esempio.it" data-testid="input-edit-email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="firstName"
render={({ field }) => (
<FormItem>
<FormLabel>Nome</FormLabel>
<FormControl>
<Input placeholder="Mario" data-testid="input-edit-firstname" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="lastName"
render={({ field }) => (
<FormItem>
<FormLabel>Cognome</FormLabel>
<FormControl>
<Input placeholder="Rossi" data-testid="input-edit-lastname" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Password (opzionale)</FormLabel>
<FormControl>
<Input type="password" placeholder="Lascia vuoto per non cambiare" data-testid="input-edit-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Ruolo</FormLabel>
<Select onValueChange={field.onChange} value={field.value} data-testid="select-edit-role">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="admin">
<div className="flex items-center gap-2">
<Shield className="h-4 w-4" />
Admin
</div>
</SelectItem>
<SelectItem value="coordinator">
<div className="flex items-center gap-2">
<UserCog className="h-4 w-4" />
Coordinatore
</div>
</SelectItem>
<SelectItem value="guard">
<div className="flex items-center gap-2">
<UsersIcon className="h-4 w-4" />
Guardia
</div>
</SelectItem>
<SelectItem value="client">
<div className="flex items-center gap-2">
<UserCheck className="h-4 w-4" />
Cliente
</div>
</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)} data-testid="button-edit-cancel">
Annulla
</Button>
<Button type="submit" disabled={updateUserMutation.isPending} data-testid="button-edit-submit">
{updateUserMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvataggio...
</>
) : (
"Salva Modifiche"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent data-testid="dialog-delete-user">
<AlertDialogHeader>
<AlertDialogTitle>Conferma Eliminazione</AlertDialogTitle>
<AlertDialogDescription>
Sei sicuro di voler eliminare l'utente <strong>{selectedUser?.firstName} {selectedUser?.lastName}</strong>?
Questa azione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-delete-cancel">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => selectedUser && deleteUserMutation.mutate(selectedUser.id)}
className="bg-destructive hover:bg-destructive/90"
data-testid="button-delete-confirm"
>
{deleteUserMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Eliminazione...
</>
) : (
"Elimina"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@ -0,0 +1,803 @@
import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient, apiRequest } from "@/lib/queryClient";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertVehicleSchema, type Vehicle, type Guard } from "@shared/schema";
import { z } from "zod";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { Car, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
const vehicleTypeLabels = {
car: "Auto",
van: "Furgone",
motorcycle: "Moto",
suv: "SUV",
};
const vehicleStatusLabels = {
available: "Disponibile",
in_use: "In uso",
maintenance: "In manutenzione",
out_of_service: "Fuori servizio",
};
const vehicleStatusColors = {
available: "bg-green-500/10 text-green-500 border-green-500/20",
in_use: "bg-blue-500/10 text-blue-500 border-blue-500/20",
maintenance: "bg-orange-500/10 text-orange-500 border-orange-500/20",
out_of_service: "bg-red-500/10 text-red-500 border-red-500/20",
};
type VehicleForm = z.infer<typeof insertVehicleSchema>;
export default function Vehicles() {
const { toast } = useToast();
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [selectedVehicle, setSelectedVehicle] = useState<Vehicle | null>(null);
const { data: vehicles, isLoading: isLoadingVehicles } = useQuery<Vehicle[]>({
queryKey: ["/api/vehicles"],
});
const { data: guards } = useQuery<Guard[]>({
queryKey: ["/api/guards"],
});
const createForm = useForm<VehicleForm>({
resolver: zodResolver(insertVehicleSchema.extend({
year: z.number().min(1900).max(new Date().getFullYear() + 1).optional().or(z.literal(null)),
mileage: z.number().min(0).optional().or(z.literal(null)),
})),
defaultValues: {
licensePlate: "",
brand: "",
model: "",
vehicleType: "car",
year: undefined,
assignedGuardId: null,
status: "available",
lastMaintenanceDate: null,
nextMaintenanceDate: null,
mileage: undefined,
notes: null,
},
});
const editForm = useForm<VehicleForm>({
resolver: zodResolver(insertVehicleSchema.extend({
year: z.number().min(1900).max(new Date().getFullYear() + 1).optional().or(z.literal(null)),
mileage: z.number().min(0).optional().or(z.literal(null)),
})),
defaultValues: {
licensePlate: "",
brand: "",
model: "",
vehicleType: "car",
year: undefined,
assignedGuardId: null,
status: "available",
lastMaintenanceDate: null,
nextMaintenanceDate: null,
mileage: undefined,
notes: null,
},
});
const createVehicleMutation = useMutation({
mutationFn: async (data: VehicleForm) => {
return apiRequest("POST", "/api/vehicles", data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/vehicles"] });
toast({
title: "Veicolo creato",
description: "Il veicolo è stato aggiunto con successo.",
});
setCreateDialogOpen(false);
createForm.reset();
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile creare il veicolo.",
variant: "destructive",
});
},
});
const updateVehicleMutation = useMutation({
mutationFn: async ({ id, data }: { id: string; data: VehicleForm }) => {
return apiRequest("PATCH", `/api/vehicles/${id}`, data);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/vehicles"] });
toast({
title: "Veicolo aggiornato",
description: "Il veicolo è stato modificato con successo.",
});
setEditDialogOpen(false);
setSelectedVehicle(null);
editForm.reset();
},
onError: (error: any) => {
toast({
title: "Errore",
description: error.message || "Impossibile aggiornare il veicolo.",
variant: "destructive",
});
},
});
const deleteVehicleMutation = useMutation({
mutationFn: async (id: string) => {
return apiRequest("DELETE", `/api/vehicles/${id}`);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/vehicles"] });
toast({
title: "Veicolo eliminato",
description: "Il veicolo è stato eliminato con successo.",
});
setDeleteDialogOpen(false);
setSelectedVehicle(null);
},
onError: () => {
toast({
title: "Errore",
description: "Impossibile eliminare il veicolo.",
variant: "destructive",
});
},
});
const handleEdit = (vehicle: Vehicle) => {
setSelectedVehicle(vehicle);
editForm.reset({
licensePlate: vehicle.licensePlate,
brand: vehicle.brand,
model: vehicle.model,
vehicleType: vehicle.vehicleType,
year: vehicle.year ?? undefined,
assignedGuardId: vehicle.assignedGuardId,
status: vehicle.status,
lastMaintenanceDate: vehicle.lastMaintenanceDate,
nextMaintenanceDate: vehicle.nextMaintenanceDate,
mileage: vehicle.mileage ?? undefined,
notes: vehicle.notes,
});
setEditDialogOpen(true);
};
const handleDelete = (vehicle: Vehicle) => {
setSelectedVehicle(vehicle);
setDeleteDialogOpen(true);
};
if (isLoadingVehicles) {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Parco Automezzi</h1>
<p className="text-muted-foreground">Gestione veicoli aziendali</p>
</div>
<Card>
<CardContent className="p-6">
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-4">
<Skeleton className="h-12 w-12 rounded" />
<div className="space-y-2 flex-1">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-3 w-32" />
</div>
<Skeleton className="h-9 w-32" />
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold" data-testid="text-page-title">
Parco Automezzi
</h1>
<p className="text-muted-foreground">
Gestisci i veicoli aziendali e le assegnazioni
</p>
</div>
<Button onClick={() => setCreateDialogOpen(true)} data-testid="button-add-vehicle">
<Plus className="h-4 w-4 mr-2" />
Aggiungi Veicolo
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Veicoli Registrati</CardTitle>
<CardDescription>
{vehicles?.length || 0} veicoli nel parco aziendale
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Targa</TableHead>
<TableHead>Veicolo</TableHead>
<TableHead>Tipo</TableHead>
<TableHead>Stato</TableHead>
<TableHead>Assegnato a</TableHead>
<TableHead className="text-right">Azioni</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{vehicles?.map((vehicle) => {
const assignedGuard = guards?.find(g => g.id === vehicle.assignedGuardId);
return (
<TableRow key={vehicle.id} data-testid={`row-vehicle-${vehicle.id}`}>
<TableCell className="font-medium" data-testid={`text-plate-${vehicle.id}`}>
{vehicle.licensePlate}
</TableCell>
<TableCell>
<div>
<p className="font-medium">{vehicle.brand} {vehicle.model}</p>
{vehicle.year && <p className="text-sm text-muted-foreground">Anno {vehicle.year}</p>}
</div>
</TableCell>
<TableCell>{vehicleTypeLabels[vehicle.vehicleType]}</TableCell>
<TableCell>
<Badge
variant="outline"
className={vehicleStatusColors[vehicle.status]}
data-testid={`badge-status-${vehicle.id}`}
>
{vehicleStatusLabels[vehicle.status]}
</Badge>
</TableCell>
<TableCell>
{assignedGuard ? (
<span className="text-sm">{assignedGuard.badgeNumber}</span>
) : (
<span className="text-sm text-muted-foreground">Non assegnato</span>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleEdit(vehicle)}
data-testid={`button-edit-${vehicle.id}`}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(vehicle)}
data-testid={`button-delete-${vehicle.id}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
{vehicles?.length === 0 && (
<div className="text-center py-8 text-muted-foreground">
<Car className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>Nessun veicolo registrato</p>
</div>
)}
</CardContent>
</Card>
{/* Create Vehicle Dialog */}
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto" data-testid="dialog-create-vehicle">
<DialogHeader>
<DialogTitle>Aggiungi Nuovo Veicolo</DialogTitle>
<DialogDescription>
Inserisci i dati del veicolo da aggiungere al parco aziendale.
</DialogDescription>
</DialogHeader>
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit((data) => createVehicleMutation.mutate(data))} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={createForm.control}
name="licensePlate"
render={({ field }) => (
<FormItem>
<FormLabel>Targa *</FormLabel>
<FormControl>
<Input placeholder="AB123CD" data-testid="input-create-plate" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="vehicleType"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo *</FormLabel>
<Select onValueChange={field.onChange} value={field.value} data-testid="select-create-type">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="car">Auto</SelectItem>
<SelectItem value="van">Furgone</SelectItem>
<SelectItem value="motorcycle">Moto</SelectItem>
<SelectItem value="suv">SUV</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<FormField
control={createForm.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Marca *</FormLabel>
<FormControl>
<Input placeholder="Fiat" data-testid="input-create-brand" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Modello *</FormLabel>
<FormControl>
<Input placeholder="500" data-testid="input-create-model" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="year"
render={({ field }) => (
<FormItem>
<FormLabel>Anno</FormLabel>
<FormControl>
<Input
type="number"
placeholder="2024"
data-testid="input-create-year"
{...field}
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={createForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Stato *</FormLabel>
<Select onValueChange={field.onChange} value={field.value} data-testid="select-create-status">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="available">Disponibile</SelectItem>
<SelectItem value="in_use">In uso</SelectItem>
<SelectItem value="maintenance">In manutenzione</SelectItem>
<SelectItem value="out_of_service">Fuori servizio</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="assignedGuardId"
render={({ field }) => (
<FormItem>
<FormLabel>Assegnato a</FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""} data-testid="select-create-guard">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleziona guardia" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Nessuno</SelectItem>
{guards?.map(guard => (
<SelectItem key={guard.id} value={guard.id}>
{guard.badgeNumber}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={createForm.control}
name="mileage"
render={({ field }) => (
<FormItem>
<FormLabel>Chilometraggio</FormLabel>
<FormControl>
<Input
type="number"
placeholder="50000"
data-testid="input-create-mileage"
{...field}
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Note</FormLabel>
<FormControl>
<Textarea
placeholder="Note aggiuntive sul veicolo..."
data-testid="input-create-notes"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} data-testid="button-create-cancel">
Annulla
</Button>
<Button type="submit" disabled={createVehicleMutation.isPending} data-testid="button-create-submit">
{createVehicleMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creazione...
</>
) : (
"Crea Veicolo"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Edit Vehicle Dialog - Same structure as create */}
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto" data-testid="dialog-edit-vehicle">
<DialogHeader>
<DialogTitle>Modifica Veicolo</DialogTitle>
<DialogDescription>
Modifica i dati del veicolo {selectedVehicle?.licensePlate}.
</DialogDescription>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit((data) => selectedVehicle && updateVehicleMutation.mutate({ id: selectedVehicle.id, data }))} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="licensePlate"
render={({ field }) => (
<FormItem>
<FormLabel>Targa *</FormLabel>
<FormControl>
<Input placeholder="AB123CD" data-testid="input-edit-plate" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="vehicleType"
render={({ field }) => (
<FormItem>
<FormLabel>Tipo *</FormLabel>
<Select onValueChange={field.onChange} value={field.value} data-testid="select-edit-type">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="car">Auto</SelectItem>
<SelectItem value="van">Furgone</SelectItem>
<SelectItem value="motorcycle">Moto</SelectItem>
<SelectItem value="suv">SUV</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-3 gap-4">
<FormField
control={editForm.control}
name="brand"
render={({ field }) => (
<FormItem>
<FormLabel>Marca *</FormLabel>
<FormControl>
<Input placeholder="Fiat" data-testid="input-edit-brand" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="model"
render={({ field }) => (
<FormItem>
<FormLabel>Modello *</FormLabel>
<FormControl>
<Input placeholder="500" data-testid="input-edit-model" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="year"
render={({ field }) => (
<FormItem>
<FormLabel>Anno</FormLabel>
<FormControl>
<Input
type="number"
placeholder="2024"
data-testid="input-edit-year"
{...field}
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<FormField
control={editForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Stato *</FormLabel>
<Select onValueChange={field.onChange} value={field.value} data-testid="select-edit-status">
<FormControl>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="available">Disponibile</SelectItem>
<SelectItem value="in_use">In uso</SelectItem>
<SelectItem value="maintenance">In manutenzione</SelectItem>
<SelectItem value="out_of_service">Fuori servizio</SelectItem>
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="assignedGuardId"
render={({ field }) => (
<FormItem>
<FormLabel>Assegnato a</FormLabel>
<Select onValueChange={field.onChange} value={field.value || ""} data-testid="select-edit-guard">
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Seleziona guardia" />
</SelectTrigger>
</FormControl>
<SelectContent>
<SelectItem value="">Nessuno</SelectItem>
{guards?.map(guard => (
<SelectItem key={guard.id} value={guard.id}>
{guard.badgeNumber}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="mileage"
render={({ field }) => (
<FormItem>
<FormLabel>Chilometraggio</FormLabel>
<FormControl>
<Input
type="number"
placeholder="50000"
data-testid="input-edit-mileage"
{...field}
value={field.value ?? ""}
onChange={e => field.onChange(e.target.value ? parseInt(e.target.value) : null)}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="notes"
render={({ field }) => (
<FormItem>
<FormLabel>Note</FormLabel>
<FormControl>
<Textarea
placeholder="Note aggiuntive sul veicolo..."
data-testid="input-edit-notes"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)} data-testid="button-edit-cancel">
Annulla
</Button>
<Button type="submit" disabled={updateVehicleMutation.isPending} data-testid="button-edit-submit">
{updateVehicleMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvataggio...
</>
) : (
"Salva Modifiche"
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent data-testid="dialog-delete-vehicle">
<AlertDialogHeader>
<AlertDialogTitle>Conferma Eliminazione</AlertDialogTitle>
<AlertDialogDescription>
Sei sicuro di voler eliminare il veicolo <strong>{selectedVehicle?.licensePlate}</strong>?
Questa azione non può essere annullata.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel data-testid="button-delete-cancel">Annulla</AlertDialogCancel>
<AlertDialogAction
onClick={() => selectedVehicle && deleteVehicleMutation.mutate(selectedVehicle.id)}
className="bg-destructive hover:bg-destructive/90"
data-testid="button-delete-confirm"
>
{deleteVehicleMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Eliminazione...
</>
) : (
"Elimina"
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

43
package-lock.json generated
View File

@ -40,8 +40,10 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0",
"@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
@ -3421,6 +3423,15 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/body-parser": {
"version": "1.19.5",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
@ -3853,6 +3864,20 @@
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -6078,12 +6103,20 @@
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
"license": "ISC"
},
"node_modules/node-gyp-build": {
"version": "4.8.3",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
"integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==",
"node_modules/node-addon-api": {
"version": "8.5.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"optional": true,
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",

View File

@ -42,8 +42,10 @@
"@radix-ui/react-toggle-group": "^1.1.3",
"@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0",
"@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5",
"bcrypt": "^6.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",

View File

@ -34,18 +34,38 @@ export function getSession() {
async function initDefaultAdmin() {
try {
// Verifica se esiste già un admin
const bcrypt = await import("bcrypt");
const users = await storage.getAllUsers();
const adminExists = users.some((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
const existingAdmin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
if (!adminExists) {
// Crea utente admin di default
if (existingAdmin) {
// Admin esiste: controlla se ha passwordHash
if (!existingAdmin.passwordHash) {
console.log(`🔄 [LocalAuth] Aggiornamento password hash per admin esistente...`);
const passwordHash = await bcrypt.hash(DEFAULT_ADMIN_PASSWORD, 10);
await storage.upsertUser({
id: existingAdmin.id,
email: existingAdmin.email,
firstName: existingAdmin.firstName || "Admin",
lastName: existingAdmin.lastName || "Sistema",
profileImageUrl: existingAdmin.profileImageUrl,
passwordHash,
});
console.log(`✅ [LocalAuth] Password hash aggiornato per: ${DEFAULT_ADMIN_EMAIL}`);
}
} else {
// Admin non esiste: crealo
const passwordHash = await bcrypt.hash(DEFAULT_ADMIN_PASSWORD, 10);
await storage.upsertUser({
id: DEFAULT_ADMIN_ID,
email: DEFAULT_ADMIN_EMAIL,
firstName: "Admin",
lastName: "Sistema",
profileImageUrl: null,
passwordHash,
});
// Imposta ruolo admin
@ -67,23 +87,31 @@ export async function setupLocalAuth(app: Express) {
// Inizializza admin di default
await initDefaultAdmin();
// Strategia passport-local
// Strategia passport-local con password hash bcrypt
passport.use(new LocalStrategy(
{ usernameField: "email" },
async (email, password, done) => {
try {
// Per demo: accetta credenziali admin di default
if (email === DEFAULT_ADMIN_EMAIL && password === DEFAULT_ADMIN_PASSWORD) {
const users = await storage.getAllUsers();
const admin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
if (admin) {
return done(null, { id: admin.id, email: admin.email });
}
}
const users = await storage.getAllUsers();
const user = users.find((u: any) => u.email === email);
// Credenziali non valide
return done(null, false, { message: "Credenziali non valide" });
if (!user) {
return done(null, false, { message: "Credenziali non valide" });
}
if (!user.passwordHash) {
return done(null, false, { message: "Credenziali non valide" });
}
// Verifica password con bcrypt
const bcrypt = await import("bcrypt");
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
if (!isValidPassword) {
return done(null, false, { message: "Credenziali non valide" });
}
return done(null, { id: user.id, email: user.email });
} catch (error) {
return done(error);
}
@ -104,10 +132,9 @@ export async function setupLocalAuth(app: Express) {
}
});
// Route login GET (redirect auto-login per compatibilità)
// Route login GET (redirect a pagina login frontend)
app.get("/api/login", (req, res) => {
// Redirect a auto-login admin per demo
res.redirect("/api/auto-login-admin");
res.redirect("/login");
});
// Route login locale POST
@ -119,42 +146,6 @@ export async function setupLocalAuth(app: Express) {
});
});
// Route auto-login admin (solo per demo/sviluppo)
app.get("/api/auto-login-admin", async (req, res) => {
if (process.env.NODE_ENV !== 'production') {
console.warn("⚠️ [LocalAuth] Auto-login admin attivato (solo sviluppo!)");
}
try {
console.log("🔍 [LocalAuth] Recupero lista utenti...");
const users = await storage.getAllUsers();
console.log(`✅ [LocalAuth] Trovati ${users.length} utenti`);
const admin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
if (admin) {
console.log(`✅ [LocalAuth] Admin trovato: ${admin.email}`);
req.login({ id: admin.id, email: admin.email }, (err) => {
if (err) {
console.error("❌ [LocalAuth] Errore req.login:", err);
return res.status(500).json({ error: "Errore auto-login", details: err.message });
}
console.log("✅ [LocalAuth] Login effettuato, redirect a /");
res.redirect("/");
});
} else {
console.error(`❌ [LocalAuth] Admin non trovato (cercato: ${DEFAULT_ADMIN_EMAIL})`);
res.status(404).json({ error: "Admin non trovato", users: users.map((u: any) => u.email) });
}
} catch (error: any) {
console.error("❌ [LocalAuth] Errore in auto-login-admin:", error);
res.status(500).json({
error: "Errore server",
message: error.message,
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
});
}
});
// Route logout
app.get("/api/logout", (req, res) => {
@ -164,9 +155,9 @@ export async function setupLocalAuth(app: Express) {
});
console.log("✅ [LocalAuth] Sistema autenticazione locale attivato");
console.log(`📧 Email admin: ${DEFAULT_ADMIN_EMAIL}`);
console.log(`🔑 Password admin: ${DEFAULT_ADMIN_PASSWORD}`);
console.log(`🔗 Auto-login: GET /api/auto-login-admin`);
console.log(`📧 Admin email: ${DEFAULT_ADMIN_EMAIL}`);
console.log(`🔑 Admin password: ${DEFAULT_ADMIN_PASSWORD}`);
console.log(`🔗 Login page: /login`);
}
export const isAuthenticated = async (req: any, res: any, next: any) => {

View File

@ -64,6 +64,54 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
app.post("/api/users", isAuthenticated, async (req: any, res) => {
try {
const currentUserId = getUserId(req);
const currentUser = await storage.getUser(currentUserId);
// Only admins can create users
if (currentUser?.role !== "admin") {
return res.status(403).json({ message: "Forbidden: Admin access required" });
}
const { email, firstName, lastName, password, role } = req.body;
if (!email || !firstName || !lastName || !password) {
return res.status(400).json({ message: "Missing required fields" });
}
// Hash password
const bcrypt = await import("bcrypt");
const passwordHash = await bcrypt.hash(password, 10);
// Generate UUID
const crypto = await import("crypto");
const userId = crypto.randomUUID();
const newUser = await storage.upsertUser({
id: userId,
email,
firstName,
lastName,
profileImageUrl: null,
passwordHash,
});
// Set role if provided
if (role) {
await storage.updateUserRole(newUser.id, role);
}
res.json(newUser);
} catch (error: any) {
console.error("Error creating user:", error);
if (error.code === '23505') { // Unique violation
return res.status(409).json({ message: "Email già esistente" });
}
res.status(500).json({ message: "Failed to create user" });
}
});
app.patch("/api/users/:id", isAuthenticated, async (req: any, res) => {
try {
const currentUserId = getUserId(req);
@ -95,6 +143,78 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
app.put("/api/users/:id", isAuthenticated, async (req: any, res) => {
try {
const currentUserId = getUserId(req);
const currentUser = await storage.getUser(currentUserId);
// Only admins can update users
if (currentUser?.role !== "admin") {
return res.status(403).json({ message: "Forbidden: Admin access required" });
}
const { email, firstName, lastName, password, role } = req.body;
const existingUser = await storage.getUser(req.params.id);
if (!existingUser) {
return res.status(404).json({ message: "User not found" });
}
// Prepare update data
const updateData: any = {
id: req.params.id,
email: email || existingUser.email,
firstName: firstName || existingUser.firstName,
lastName: lastName || existingUser.lastName,
profileImageUrl: existingUser.profileImageUrl,
};
// Hash new password if provided
if (password) {
const bcrypt = await import("bcrypt");
updateData.passwordHash = await bcrypt.hash(password, 10);
}
const updated = await storage.upsertUser(updateData);
// Update role if provided and not changing own role
if (role && req.params.id !== currentUserId) {
await storage.updateUserRole(req.params.id, role);
}
res.json(updated);
} catch (error: any) {
console.error("Error updating user:", error);
if (error.code === '23505') {
return res.status(409).json({ message: "Email già esistente" });
}
res.status(500).json({ message: "Failed to update user" });
}
});
app.delete("/api/users/:id", isAuthenticated, async (req: any, res) => {
try {
const currentUserId = getUserId(req);
const currentUser = await storage.getUser(currentUserId);
// Only admins can delete users
if (currentUser?.role !== "admin") {
return res.status(403).json({ message: "Forbidden: Admin access required" });
}
// Prevent admins from deleting themselves
if (req.params.id === currentUserId) {
return res.status(403).json({ message: "Cannot delete your own account" });
}
await storage.deleteUser(req.params.id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting user:", error);
res.status(500).json({ message: "Failed to delete user" });
}
});
// ============= GUARD ROUTES =============
app.get("/api/guards", isAuthenticated, async (req, res) => {
try {
@ -174,6 +294,59 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});
// ============= VEHICLE ROUTES =============
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
try {
const vehicles = await storage.getAllVehicles();
res.json(vehicles);
} catch (error) {
console.error("Error fetching vehicles:", error);
res.status(500).json({ message: "Failed to fetch vehicles" });
}
});
app.post("/api/vehicles", isAuthenticated, async (req, res) => {
try {
const vehicle = await storage.createVehicle(req.body);
res.json(vehicle);
} catch (error: any) {
console.error("Error creating vehicle:", error);
if (error.code === '23505') {
return res.status(409).json({ message: "Targa già esistente" });
}
res.status(500).json({ message: "Failed to create vehicle" });
}
});
app.patch("/api/vehicles/:id", isAuthenticated, async (req, res) => {
try {
const updated = await storage.updateVehicle(req.params.id, req.body);
if (!updated) {
return res.status(404).json({ message: "Vehicle not found" });
}
res.json(updated);
} catch (error: any) {
console.error("Error updating vehicle:", error);
if (error.code === '23505') {
return res.status(409).json({ message: "Targa già esistente" });
}
res.status(500).json({ message: "Failed to update vehicle" });
}
});
app.delete("/api/vehicles/:id", isAuthenticated, async (req, res) => {
try {
const deleted = await storage.deleteVehicle(req.params.id);
if (!deleted) {
return res.status(404).json({ message: "Vehicle not found" });
}
res.json({ success: true });
} catch (error) {
console.error("Error deleting vehicle:", error);
res.status(500).json({ message: "Failed to delete vehicle" });
}
});
// ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => {
try {

View File

@ -3,6 +3,7 @@ import {
users,
guards,
certifications,
vehicles,
sites,
shifts,
shiftAssignments,
@ -20,6 +21,8 @@ import {
type InsertGuard,
type Certification,
type InsertCertification,
type Vehicle,
type InsertVehicle,
type Site,
type InsertSite,
type Shift,
@ -174,6 +177,11 @@ export class DatabaseStorage implements IStorage {
return updated;
}
async deleteUser(id: string): Promise<User | undefined> {
const [deleted] = await db.delete(users).where(eq(users.id, id)).returning();
return deleted;
}
// Guard operations
async getAllGuards(): Promise<Guard[]> {
return await db.select().from(guards);
@ -203,6 +211,35 @@ export class DatabaseStorage implements IStorage {
return deleted;
}
// Vehicle operations
async getAllVehicles(): Promise<Vehicle[]> {
return await db.select().from(vehicles).orderBy(desc(vehicles.createdAt));
}
async getVehicle(id: string): Promise<Vehicle | undefined> {
const [vehicle] = await db.select().from(vehicles).where(eq(vehicles.id, id));
return vehicle;
}
async createVehicle(vehicle: InsertVehicle): Promise<Vehicle> {
const [newVehicle] = await db.insert(vehicles).values(vehicle).returning();
return newVehicle;
}
async updateVehicle(id: string, vehicleData: Partial<InsertVehicle>): Promise<Vehicle | undefined> {
const [updated] = await db
.update(vehicles)
.set({ ...vehicleData, updatedAt: new Date() })
.where(eq(vehicles.id, id))
.returning();
return updated;
}
async deleteVehicle(id: string): Promise<Vehicle | undefined> {
const [deleted] = await db.delete(vehicles).where(eq(vehicles.id, id)).returning();
return deleted;
}
// Certification operations
async getCertificationsByGuard(guardId: string): Promise<Certification[]> {
return await db

View File

@ -71,6 +71,20 @@ export const sitePreferenceTypeEnum = pgEnum("site_preference_type", [
"blacklisted", // Non assegnare mai questo operatore a questo sito
]);
export const vehicleStatusEnum = pgEnum("vehicle_status", [
"available", // Disponibile
"in_use", // In uso
"maintenance", // In manutenzione
"out_of_service", // Fuori servizio
]);
export const vehicleTypeEnum = pgEnum("vehicle_type", [
"car", // Auto
"van", // Furgone
"motorcycle", // Moto
"suv", // SUV
]);
// ============= SESSION & AUTH TABLES (Replit Auth) =============
// Session storage table - mandatory for Replit Auth
@ -91,6 +105,7 @@ export const users = pgTable("users", {
firstName: varchar("first_name"),
lastName: varchar("last_name"),
profileImageUrl: varchar("profile_image_url"),
passwordHash: varchar("password_hash"), // For local auth - bcrypt hash
role: userRoleEnum("role").notNull().default("guard"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
@ -127,6 +142,30 @@ export const certifications = pgTable("certifications", {
createdAt: timestamp("created_at").defaultNow(),
});
// ============= VEHICLES (PARCO AUTOMEZZI) =============
export const vehicles = pgTable("vehicles", {
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
licensePlate: varchar("license_plate").notNull().unique(), // Targa
brand: varchar("brand").notNull(), // Marca (es: Fiat, Volkswagen)
model: varchar("model").notNull(), // Modello
vehicleType: vehicleTypeEnum("vehicle_type").notNull(),
year: integer("year"), // Anno immatricolazione
// Assegnazione
assignedGuardId: varchar("assigned_guard_id").references(() => guards.id, { onDelete: "set null" }),
// Stato e manutenzione
status: vehicleStatusEnum("status").notNull().default("available"),
lastMaintenanceDate: date("last_maintenance_date"),
nextMaintenanceDate: date("next_maintenance_date"),
mileage: integer("mileage"), // Chilometraggio
notes: text("notes"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").defaultNow(),
});
// ============= SITES & CONTRACTS =============
export const sites = pgTable("sites", {
@ -380,6 +419,14 @@ export const guardsRelations = relations(guards, ({ one, many }) => ({
sitePreferences: many(sitePreferences),
trainingCourses: many(trainingCourses),
absences: many(absences),
assignedVehicles: many(vehicles),
}));
export const vehiclesRelations = relations(vehicles, ({ one }) => ({
assignedGuard: one(guards, {
fields: [vehicles.assignedGuardId],
references: [guards.id],
}),
}));
export const certificationsRelations = relations(certifications, ({ one }) => ({
@ -502,9 +549,28 @@ export const insertUserSchema = createInsertSchema(users).pick({
firstName: true,
lastName: true,
profileImageUrl: true,
passwordHash: true,
role: true,
});
// Form schema with plain password (will be hashed on backend)
export const insertUserFormSchema = z.object({
email: z.string().email("Email non valida"),
firstName: z.string().min(1, "Nome obbligatorio"),
lastName: z.string().min(1, "Cognome obbligatorio"),
password: z.string().min(6, "Password minimo 6 caratteri"),
role: z.enum(["admin", "coordinator", "guard", "client"]).default("guard"),
});
// Update user form schema (password optional)
export const updateUserFormSchema = z.object({
email: z.string().email("Email non valida").optional(),
firstName: z.string().min(1, "Nome obbligatorio").optional(),
lastName: z.string().min(1, "Cognome obbligatorio").optional(),
password: z.string().min(6, "Password minimo 6 caratteri").optional(),
role: z.enum(["admin", "coordinator", "guard", "client"]).optional(),
});
export const insertGuardSchema = createInsertSchema(guards).omit({
id: true,
createdAt: true,
@ -517,6 +583,12 @@ export const insertCertificationSchema = createInsertSchema(certifications).omit
status: true,
});
export const insertVehicleSchema = createInsertSchema(vehicles).omit({
id: true,
createdAt: true,
updatedAt: true,
});
export const insertSiteSchema = createInsertSchema(sites).omit({
id: true,
createdAt: true,
@ -604,6 +676,9 @@ export type Guard = typeof guards.$inferSelect;
export type InsertCertification = z.infer<typeof insertCertificationSchema>;
export type Certification = typeof certifications.$inferSelect;
export type InsertVehicle = z.infer<typeof insertVehicleSchema>;
export type Vehicle = typeof vehicles.$inferSelect;
export type InsertSite = z.infer<typeof insertSiteSchema>;
export type Site = typeof sites.$inferSelect;