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:
parent
09ff76e02d
commit
0203c9694d
@ -9,6 +9,7 @@ import { AppSidebar } from "@/components/app-sidebar";
|
|||||||
import { useAuth } from "@/hooks/useAuth";
|
import { useAuth } from "@/hooks/useAuth";
|
||||||
import NotFound from "@/pages/not-found";
|
import NotFound from "@/pages/not-found";
|
||||||
import Landing from "@/pages/landing";
|
import Landing from "@/pages/landing";
|
||||||
|
import Login from "@/pages/login";
|
||||||
import Dashboard from "@/pages/dashboard";
|
import Dashboard from "@/pages/dashboard";
|
||||||
import Guards from "@/pages/guards";
|
import Guards from "@/pages/guards";
|
||||||
import Sites from "@/pages/sites";
|
import Sites from "@/pages/sites";
|
||||||
@ -17,12 +18,14 @@ import Reports from "@/pages/reports";
|
|||||||
import Notifications from "@/pages/notifications";
|
import Notifications from "@/pages/notifications";
|
||||||
import Users from "@/pages/users";
|
import Users from "@/pages/users";
|
||||||
import Planning from "@/pages/planning";
|
import Planning from "@/pages/planning";
|
||||||
|
import Vehicles from "@/pages/vehicles";
|
||||||
|
|
||||||
function Router() {
|
function Router() {
|
||||||
const { isAuthenticated, isLoading } = useAuth();
|
const { isAuthenticated, isLoading } = useAuth();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
|
<Route path="/login" component={Login} />
|
||||||
{isLoading || !isAuthenticated ? (
|
{isLoading || !isAuthenticated ? (
|
||||||
<Route path="/" component={Landing} />
|
<Route path="/" component={Landing} />
|
||||||
) : (
|
) : (
|
||||||
@ -30,6 +33,7 @@ function Router() {
|
|||||||
<Route path="/" component={Dashboard} />
|
<Route path="/" component={Dashboard} />
|
||||||
<Route path="/guards" component={Guards} />
|
<Route path="/guards" component={Guards} />
|
||||||
<Route path="/sites" component={Sites} />
|
<Route path="/sites" component={Sites} />
|
||||||
|
<Route path="/vehicles" component={Vehicles} />
|
||||||
<Route path="/shifts" component={Shifts} />
|
<Route path="/shifts" component={Shifts} />
|
||||||
<Route path="/planning" component={Planning} />
|
<Route path="/planning" component={Planning} />
|
||||||
<Route path="/reports" component={Reports} />
|
<Route path="/reports" component={Reports} />
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
UserCog,
|
UserCog,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
|
Car,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Link, useLocation } from "wouter";
|
import { Link, useLocation } from "wouter";
|
||||||
import {
|
import {
|
||||||
@ -59,6 +60,12 @@ const menuItems = [
|
|||||||
icon: MapPin,
|
icon: MapPin,
|
||||||
roles: ["admin", "coordinator", "client"],
|
roles: ["admin", "coordinator", "client"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "Parco Automezzi",
|
||||||
|
url: "/vehicles",
|
||||||
|
icon: Car,
|
||||||
|
roles: ["admin", "coordinator"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "Report",
|
title: "Report",
|
||||||
url: "/reports",
|
url: "/reports",
|
||||||
|
|||||||
158
client/src/pages/login.tsx
Normal file
158
client/src/pages/login.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
|
import { useState } from "react";
|
||||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||||
import { useAuth } from "@/hooks/useAuth";
|
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@ -17,6 +21,24 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table";
|
} 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 {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@ -24,11 +46,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "@/components/ui/form";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
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 { 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 = {
|
const roleIcons = {
|
||||||
admin: Shield,
|
admin: Shield,
|
||||||
@ -51,35 +83,128 @@ const roleColors = {
|
|||||||
client: "bg-orange-500/10 text-orange-500 border-orange-500/20",
|
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() {
|
export default function Users() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const { toast } = useToast();
|
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[]>({
|
const { data: users, isLoading } = useQuery<User[]>({
|
||||||
queryKey: ["/api/users"],
|
queryKey: ["/api/users"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateRoleMutation = useMutation({
|
const createForm = useForm<CreateUserForm>({
|
||||||
mutationFn: async ({ userId, role }: { userId: string; role: string }) => {
|
resolver: zodResolver(insertUserFormSchema),
|
||||||
return apiRequest("PATCH", `/api/users/${userId}`, { role });
|
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: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||||
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
|
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
|
||||||
toast({
|
toast({
|
||||||
title: "Ruolo aggiornato",
|
title: "Utente aggiornato",
|
||||||
description: "Il ruolo dell'utente è stato modificato con successo.",
|
description: "L'utente è stato modificato con successo.",
|
||||||
});
|
});
|
||||||
|
setEditDialogOpen(false);
|
||||||
|
setSelectedUser(null);
|
||||||
|
editForm.reset();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error: any) => {
|
||||||
toast({
|
toast({
|
||||||
title: "Errore",
|
title: "Errore",
|
||||||
description: "Impossibile aggiornare il ruolo dell'utente.",
|
description: error.message || "Impossibile aggiornare l'utente.",
|
||||||
variant: "destructive",
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@ -111,13 +236,19 @@ export default function Users() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-3xl font-bold" data-testid="text-page-title">
|
<div>
|
||||||
Gestione Utenti
|
<h1 className="text-3xl font-bold" data-testid="text-page-title">
|
||||||
</h1>
|
Gestione Utenti
|
||||||
<p className="text-muted-foreground">
|
</h1>
|
||||||
Gestisci utenti e permessi del sistema VigilanzaTurni
|
<p className="text-muted-foreground">
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@ -133,8 +264,8 @@ export default function Users() {
|
|||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>Utente</TableHead>
|
<TableHead>Utente</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Ruolo Attuale</TableHead>
|
<TableHead>Ruolo</TableHead>
|
||||||
<TableHead>Modifica Ruolo</TableHead>
|
<TableHead className="text-right">Azioni</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
@ -181,45 +312,26 @@ export default function Users() {
|
|||||||
{roleLabels[user.role]}
|
{roleLabels[user.role]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell className="text-right">
|
||||||
<Select
|
<div className="flex justify-end gap-2">
|
||||||
value={user.role}
|
<Button
|
||||||
onValueChange={(role) =>
|
variant="outline"
|
||||||
updateRoleMutation.mutate({ userId: user.id, role })
|
size="sm"
|
||||||
}
|
onClick={() => handleEdit(user)}
|
||||||
disabled={isCurrentUser || updateRoleMutation.isPending}
|
data-testid={`button-edit-${user.id}`}
|
||||||
data-testid={`select-role-${user.id}`}
|
>
|
||||||
>
|
<Pencil className="h-4 w-4" />
|
||||||
<SelectTrigger className="w-40">
|
</Button>
|
||||||
<SelectValue />
|
<Button
|
||||||
</SelectTrigger>
|
variant="outline"
|
||||||
<SelectContent>
|
size="sm"
|
||||||
<SelectItem value="admin" data-testid={`option-admin-${user.id}`}>
|
onClick={() => handleDelete(user)}
|
||||||
<div className="flex items-center gap-2">
|
disabled={isCurrentUser}
|
||||||
<Shield className="h-4 w-4" />
|
data-testid={`button-delete-${user.id}`}
|
||||||
Admin
|
>
|
||||||
</div>
|
<Trash2 className="h-4 w-4" />
|
||||||
</SelectItem>
|
</Button>
|
||||||
<SelectItem value="coordinator" data-testid={`option-coordinator-${user.id}`}>
|
</div>
|
||||||
<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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
);
|
);
|
||||||
@ -234,6 +346,292 @@ export default function Users() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
803
client/src/pages/vehicles.tsx
Normal file
803
client/src/pages/vehicles.tsx
Normal 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
43
package-lock.json
generated
@ -40,8 +40,10 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/memoizee": "^0.4.12",
|
"@types/memoizee": "^0.4.12",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
@ -3421,6 +3423,15 @@
|
|||||||
"@babel/types": "^7.20.7"
|
"@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": {
|
"node_modules/@types/body-parser": {
|
||||||
"version": "1.19.5",
|
"version": "1.19.5",
|
||||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||||
@ -3853,6 +3864,20 @@
|
|||||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@ -6078,12 +6103,20 @@
|
|||||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/node-gyp-build": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "4.8.3",
|
"version": "8.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||||
"integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==",
|
"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",
|
"license": "MIT",
|
||||||
"optional": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"node-gyp-build": "bin.js",
|
"node-gyp-build": "bin.js",
|
||||||
"node-gyp-build-optional": "optional.js",
|
"node-gyp-build-optional": "optional.js",
|
||||||
|
|||||||
@ -42,8 +42,10 @@
|
|||||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||||
"@radix-ui/react-tooltip": "^1.2.0",
|
"@radix-ui/react-tooltip": "^1.2.0",
|
||||||
"@tanstack/react-query": "^5.60.5",
|
"@tanstack/react-query": "^5.60.5",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
"@types/memoizee": "^0.4.12",
|
"@types/memoizee": "^0.4.12",
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
"bcrypt": "^6.0.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
|
|||||||
@ -34,18 +34,38 @@ export function getSession() {
|
|||||||
|
|
||||||
async function initDefaultAdmin() {
|
async function initDefaultAdmin() {
|
||||||
try {
|
try {
|
||||||
// Verifica se esiste già un admin
|
const bcrypt = await import("bcrypt");
|
||||||
const users = await storage.getAllUsers();
|
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 (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);
|
||||||
|
|
||||||
if (!adminExists) {
|
|
||||||
// Crea utente admin di default
|
|
||||||
await storage.upsertUser({
|
await storage.upsertUser({
|
||||||
id: DEFAULT_ADMIN_ID,
|
id: DEFAULT_ADMIN_ID,
|
||||||
email: DEFAULT_ADMIN_EMAIL,
|
email: DEFAULT_ADMIN_EMAIL,
|
||||||
firstName: "Admin",
|
firstName: "Admin",
|
||||||
lastName: "Sistema",
|
lastName: "Sistema",
|
||||||
profileImageUrl: null,
|
profileImageUrl: null,
|
||||||
|
passwordHash,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Imposta ruolo admin
|
// Imposta ruolo admin
|
||||||
@ -67,23 +87,31 @@ export async function setupLocalAuth(app: Express) {
|
|||||||
// Inizializza admin di default
|
// Inizializza admin di default
|
||||||
await initDefaultAdmin();
|
await initDefaultAdmin();
|
||||||
|
|
||||||
// Strategia passport-local
|
// Strategia passport-local con password hash bcrypt
|
||||||
passport.use(new LocalStrategy(
|
passport.use(new LocalStrategy(
|
||||||
{ usernameField: "email" },
|
{ usernameField: "email" },
|
||||||
async (email, password, done) => {
|
async (email, password, done) => {
|
||||||
try {
|
try {
|
||||||
// Per demo: accetta credenziali admin di default
|
const users = await storage.getAllUsers();
|
||||||
if (email === DEFAULT_ADMIN_EMAIL && password === DEFAULT_ADMIN_PASSWORD) {
|
const user = users.find((u: any) => u.email === email);
|
||||||
const users = await storage.getAllUsers();
|
|
||||||
const admin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
|
|
||||||
|
|
||||||
if (admin) {
|
if (!user) {
|
||||||
return done(null, { id: admin.id, email: admin.email });
|
return done(null, false, { message: "Credenziali non valide" });
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Credenziali non valide
|
if (!user.passwordHash) {
|
||||||
return done(null, false, { message: "Credenziali non valide" });
|
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) {
|
} catch (error) {
|
||||||
return done(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) => {
|
app.get("/api/login", (req, res) => {
|
||||||
// Redirect a auto-login admin per demo
|
res.redirect("/login");
|
||||||
res.redirect("/api/auto-login-admin");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Route login locale POST
|
// 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
|
// Route logout
|
||||||
app.get("/api/logout", (req, res) => {
|
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("✅ [LocalAuth] Sistema autenticazione locale attivato");
|
||||||
console.log(`📧 Email admin: ${DEFAULT_ADMIN_EMAIL}`);
|
console.log(`📧 Admin email: ${DEFAULT_ADMIN_EMAIL}`);
|
||||||
console.log(`🔑 Password admin: ${DEFAULT_ADMIN_PASSWORD}`);
|
console.log(`🔑 Admin password: ${DEFAULT_ADMIN_PASSWORD}`);
|
||||||
console.log(`🔗 Auto-login: GET /api/auto-login-admin`);
|
console.log(`🔗 Login page: /login`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const isAuthenticated = async (req: any, res: any, next: any) => {
|
export const isAuthenticated = async (req: any, res: any, next: any) => {
|
||||||
|
|||||||
173
server/routes.ts
173
server/routes.ts
@ -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) => {
|
app.patch("/api/users/:id", isAuthenticated, async (req: any, res) => {
|
||||||
try {
|
try {
|
||||||
const currentUserId = getUserId(req);
|
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 =============
|
// ============= GUARD ROUTES =============
|
||||||
app.get("/api/guards", isAuthenticated, async (req, res) => {
|
app.get("/api/guards", isAuthenticated, async (req, res) => {
|
||||||
try {
|
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 =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
guards,
|
guards,
|
||||||
certifications,
|
certifications,
|
||||||
|
vehicles,
|
||||||
sites,
|
sites,
|
||||||
shifts,
|
shifts,
|
||||||
shiftAssignments,
|
shiftAssignments,
|
||||||
@ -20,6 +21,8 @@ import {
|
|||||||
type InsertGuard,
|
type InsertGuard,
|
||||||
type Certification,
|
type Certification,
|
||||||
type InsertCertification,
|
type InsertCertification,
|
||||||
|
type Vehicle,
|
||||||
|
type InsertVehicle,
|
||||||
type Site,
|
type Site,
|
||||||
type InsertSite,
|
type InsertSite,
|
||||||
type Shift,
|
type Shift,
|
||||||
@ -174,6 +177,11 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return updated;
|
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
|
// Guard operations
|
||||||
async getAllGuards(): Promise<Guard[]> {
|
async getAllGuards(): Promise<Guard[]> {
|
||||||
return await db.select().from(guards);
|
return await db.select().from(guards);
|
||||||
@ -203,6 +211,35 @@ export class DatabaseStorage implements IStorage {
|
|||||||
return deleted;
|
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
|
// Certification operations
|
||||||
async getCertificationsByGuard(guardId: string): Promise<Certification[]> {
|
async getCertificationsByGuard(guardId: string): Promise<Certification[]> {
|
||||||
return await db
|
return await db
|
||||||
|
|||||||
@ -71,6 +71,20 @@ export const sitePreferenceTypeEnum = pgEnum("site_preference_type", [
|
|||||||
"blacklisted", // Non assegnare mai questo operatore a questo sito
|
"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 & AUTH TABLES (Replit Auth) =============
|
||||||
|
|
||||||
// Session storage table - mandatory for Replit Auth
|
// Session storage table - mandatory for Replit Auth
|
||||||
@ -91,6 +105,7 @@ export const users = pgTable("users", {
|
|||||||
firstName: varchar("first_name"),
|
firstName: varchar("first_name"),
|
||||||
lastName: varchar("last_name"),
|
lastName: varchar("last_name"),
|
||||||
profileImageUrl: varchar("profile_image_url"),
|
profileImageUrl: varchar("profile_image_url"),
|
||||||
|
passwordHash: varchar("password_hash"), // For local auth - bcrypt hash
|
||||||
role: userRoleEnum("role").notNull().default("guard"),
|
role: userRoleEnum("role").notNull().default("guard"),
|
||||||
createdAt: timestamp("created_at").defaultNow(),
|
createdAt: timestamp("created_at").defaultNow(),
|
||||||
updatedAt: timestamp("updated_at").defaultNow(),
|
updatedAt: timestamp("updated_at").defaultNow(),
|
||||||
@ -127,6 +142,30 @@ export const certifications = pgTable("certifications", {
|
|||||||
createdAt: timestamp("created_at").defaultNow(),
|
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 =============
|
// ============= SITES & CONTRACTS =============
|
||||||
|
|
||||||
export const sites = pgTable("sites", {
|
export const sites = pgTable("sites", {
|
||||||
@ -380,6 +419,14 @@ export const guardsRelations = relations(guards, ({ one, many }) => ({
|
|||||||
sitePreferences: many(sitePreferences),
|
sitePreferences: many(sitePreferences),
|
||||||
trainingCourses: many(trainingCourses),
|
trainingCourses: many(trainingCourses),
|
||||||
absences: many(absences),
|
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 }) => ({
|
export const certificationsRelations = relations(certifications, ({ one }) => ({
|
||||||
@ -502,9 +549,28 @@ export const insertUserSchema = createInsertSchema(users).pick({
|
|||||||
firstName: true,
|
firstName: true,
|
||||||
lastName: true,
|
lastName: true,
|
||||||
profileImageUrl: true,
|
profileImageUrl: true,
|
||||||
|
passwordHash: true,
|
||||||
role: 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({
|
export const insertGuardSchema = createInsertSchema(guards).omit({
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@ -517,6 +583,12 @@ export const insertCertificationSchema = createInsertSchema(certifications).omit
|
|||||||
status: true,
|
status: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const insertVehicleSchema = createInsertSchema(vehicles).omit({
|
||||||
|
id: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
});
|
||||||
|
|
||||||
export const insertSiteSchema = createInsertSchema(sites).omit({
|
export const insertSiteSchema = createInsertSchema(sites).omit({
|
||||||
id: true,
|
id: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
@ -604,6 +676,9 @@ export type Guard = typeof guards.$inferSelect;
|
|||||||
export type InsertCertification = z.infer<typeof insertCertificationSchema>;
|
export type InsertCertification = z.infer<typeof insertCertificationSchema>;
|
||||||
export type Certification = typeof certifications.$inferSelect;
|
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 InsertSite = z.infer<typeof insertSiteSchema>;
|
||||||
export type Site = typeof sites.$inferSelect;
|
export type Site = typeof sites.$inferSelect;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user