Compare commits

...

4 Commits

Author SHA1 Message Date
Marco Lanzara
743ec4261f Deploy: 2025-10-17 06:25:23 2025-10-17 06:25:27 +00:00
marco370
dcb6a87ef8 Update Italian translation for user interface elements
Translate UI strings from English to Italian to improve usability for Italian-speaking users.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/Z1LDqzu
2025-10-16 17:57:11 +00:00
marco370
b81f1253ac Add system parameters configuration for contract rules
Introduce a new section in the application for managing contract parameters. This includes backend API endpoints for fetching and updating contract parameters, schema definitions for these parameters, and a frontend page to display and edit them. Admins and coordinators can now configure various aspects of contract rules and shift planning.

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/Z1LDqzu
2025-10-16 17:56:46 +00:00
marco370
0203c9694d 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
2025-10-16 17:41:22 +00:00
12 changed files with 2273 additions and 120 deletions

View File

@ -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,15 @@ 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";
import Parameters from "@/pages/parameters";
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,11 +34,13 @@ 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} />
<Route path="/notifications" component={Notifications} /> <Route path="/notifications" component={Notifications} />
<Route path="/users" component={Users} /> <Route path="/users" component={Users} />
<Route path="/parameters" component={Parameters} />
</> </>
)} )}
<Route component={NotFound} /> <Route component={NotFound} />

View File

@ -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",
@ -77,6 +84,12 @@ const menuItems = [
icon: UserCog, icon: UserCog,
roles: ["admin"], roles: ["admin"],
}, },
{
title: "Parametri",
url: "/parameters",
icon: Settings,
roles: ["admin", "coordinator"],
},
]; ];
export function AppSidebar() { export function AppSidebar() {

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

@ -0,0 +1,370 @@
import { useState, useEffect } from "react";
import { useQuery, useMutation } from "@tanstack/react-query";
import { queryClient } from "@/lib/queryClient";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useToast } from "@/hooks/use-toast";
import { Loader2, Save, Settings } from "lucide-react";
import type { ContractParameters } from "@shared/schema";
export default function Parameters() {
const { toast } = useToast();
const [isEditing, setIsEditing] = useState(false);
const { data: parameters, isLoading } = useQuery<ContractParameters>({
queryKey: ["/api/contract-parameters"],
});
const [formData, setFormData] = useState<Partial<ContractParameters>>({});
// Sync formData with parameters when they load
useEffect(() => {
if (parameters && !isEditing) {
setFormData(parameters);
}
}, [parameters, isEditing]);
const updateMutation = useMutation({
mutationFn: async (data: Partial<ContractParameters>) => {
if (!parameters?.id) throw new Error("No parameters ID");
const response = await fetch(`/api/contract-parameters/${parameters.id}`, {
method: "PUT",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});
if (!response.ok) throw new Error("Failed to update parameters");
return response.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/contract-parameters"] });
toast({
title: "Parametri aggiornati",
description: "I parametri CCNL sono stati aggiornati con successo.",
});
setIsEditing(false);
},
onError: (error: Error) => {
toast({
title: "Errore",
description: error.message || "Impossibile aggiornare i parametri",
variant: "destructive",
});
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate all numeric fields are present and valid
const requiredNumericFields = [
'maxHoursPerDay', 'maxOvertimePerDay', 'maxHoursPerWeek', 'maxOvertimePerWeek',
'minDailyRestHours', 'minDailyRestHoursReduced', 'maxDailyRestReductionsPerMonth',
'maxDailyRestReductionsPerYear', 'minWeeklyRestHours', 'pauseMinutesIfOver6Hours'
];
for (const field of requiredNumericFields) {
const value = (formData as any)[field];
if (value === undefined || value === null || isNaN(value)) {
toast({
title: "Errore Validazione",
description: `Il campo ${field} deve essere un numero valido`,
variant: "destructive",
});
return;
}
}
updateMutation.mutate(formData);
};
const handleCancel = () => {
setFormData(parameters || {});
setIsEditing(false);
};
if (isLoading) {
return (
<div className="flex items-center justify-center h-full">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
if (!parameters) {
return (
<div className="flex items-center justify-center h-full">
<p className="text-muted-foreground">Nessun parametro configurato</p>
</div>
);
}
return (
<div className="p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2" data-testid="heading-parameters">
<Settings className="h-8 w-8" />
Parametri Sistema
</h1>
<p className="text-muted-foreground mt-1">
Configurazione limiti CCNL e regole turni
</p>
</div>
{!isEditing ? (
<Button onClick={() => setIsEditing(true)} data-testid="button-edit-parameters">
Modifica Parametri
</Button>
) : (
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleCancel}
disabled={updateMutation.isPending}
data-testid="button-cancel-edit"
>
Annulla
</Button>
<Button
onClick={handleSubmit}
disabled={updateMutation.isPending}
data-testid="button-save-parameters"
>
{updateMutation.isPending ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Salvataggio...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Salva Modifiche
</>
)}
</Button>
</div>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Limiti Orari */}
<Card>
<CardHeader>
<CardTitle>Limiti Orari</CardTitle>
<CardDescription>Orari massimi giornalieri e settimanali secondo CCNL</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="maxHoursPerDay">Ore Massime Giornaliere</Label>
<Input
id="maxHoursPerDay"
type="number"
value={formData.maxHoursPerDay || 8}
onChange={(e) => setFormData({ ...formData, maxHoursPerDay: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-hours-per-day"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxOvertimePerDay">Straordinario Max Giornaliero (ore)</Label>
<Input
id="maxOvertimePerDay"
type="number"
value={formData.maxOvertimePerDay || 2}
onChange={(e) => setFormData({ ...formData, maxOvertimePerDay: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-overtime-per-day"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxHoursPerWeek">Ore Massime Settimanali</Label>
<Input
id="maxHoursPerWeek"
type="number"
value={formData.maxHoursPerWeek || 40}
onChange={(e) => setFormData({ ...formData, maxHoursPerWeek: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-hours-per-week"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxOvertimePerWeek">Straordinario Max Settimanale (ore)</Label>
<Input
id="maxOvertimePerWeek"
type="number"
value={formData.maxOvertimePerWeek || 8}
onChange={(e) => setFormData({ ...formData, maxOvertimePerWeek: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-overtime-per-week"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxNightHoursPerWeek">Ore Notturne Max Settimanali (22:00-06:00)</Label>
<Input
id="maxNightHoursPerWeek"
type="number"
value={formData.maxNightHoursPerWeek || 48}
onChange={(e) => setFormData({ ...formData, maxNightHoursPerWeek: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-night-hours-per-week"
/>
</div>
</CardContent>
</Card>
{/* Riposi Obbligatori */}
<Card>
<CardHeader>
<CardTitle>Riposi Obbligatori</CardTitle>
<CardDescription>Riposi minimi giornalieri e settimanali secondo CCNL</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="minDailyRestHours">Riposo Giornaliero Minimo (ore)</Label>
<Input
id="minDailyRestHours"
type="number"
value={formData.minDailyRestHours || 11}
onChange={(e) => setFormData({ ...formData, minDailyRestHours: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-min-daily-rest-hours"
/>
</div>
<div className="space-y-2">
<Label htmlFor="minDailyRestHoursReduced">Riposo Giornaliero Ridotto (ore)</Label>
<Input
id="minDailyRestHoursReduced"
type="number"
value={formData.minDailyRestHoursReduced || 9}
onChange={(e) => setFormData({ ...formData, minDailyRestHoursReduced: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-min-daily-rest-hours-reduced"
/>
<p className="text-sm text-muted-foreground">Deroga CCNL - max 12 volte/anno</p>
</div>
<div className="space-y-2">
<Label htmlFor="maxDailyRestReductionsPerMonth">Riduzioni Riposo Max al Mese</Label>
<Input
id="maxDailyRestReductionsPerMonth"
type="number"
value={formData.maxDailyRestReductionsPerMonth || 3}
onChange={(e) => setFormData({ ...formData, maxDailyRestReductionsPerMonth: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-daily-rest-reductions-per-month"
/>
</div>
<div className="space-y-2">
<Label htmlFor="maxDailyRestReductionsPerYear">Riduzioni Riposo Max all'Anno</Label>
<Input
id="maxDailyRestReductionsPerYear"
type="number"
value={formData.maxDailyRestReductionsPerYear || 12}
onChange={(e) => setFormData({ ...formData, maxDailyRestReductionsPerYear: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-max-daily-rest-reductions-per-year"
/>
</div>
<div className="space-y-2">
<Label htmlFor="minWeeklyRestHours">Riposo Settimanale Minimo (ore)</Label>
<Input
id="minWeeklyRestHours"
type="number"
value={formData.minWeeklyRestHours || 24}
onChange={(e) => setFormData({ ...formData, minWeeklyRestHours: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-min-weekly-rest-hours"
/>
</div>
<div className="space-y-2">
<Label htmlFor="pauseMinutesIfOver6Hours">Pausa se Turno {'>'} 6 ore (minuti)</Label>
<Input
id="pauseMinutesIfOver6Hours"
type="number"
value={formData.pauseMinutesIfOver6Hours || 10}
onChange={(e) => setFormData({ ...formData, pauseMinutesIfOver6Hours: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-pause-minutes-if-over-6-hours"
/>
</div>
</CardContent>
</Card>
{/* Maggiorazioni */}
<Card>
<CardHeader>
<CardTitle>Maggiorazioni Retributive</CardTitle>
<CardDescription>Percentuali maggiorazione per festivi, notturni e straordinari</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="holidayPayIncrease">Maggiorazione Festivi (%)</Label>
<Input
id="holidayPayIncrease"
type="number"
value={formData.holidayPayIncrease || 30}
onChange={(e) => setFormData({ ...formData, holidayPayIncrease: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-holiday-pay-increase"
/>
</div>
<div className="space-y-2">
<Label htmlFor="nightPayIncrease">Maggiorazione Notturni (%)</Label>
<Input
id="nightPayIncrease"
type="number"
value={formData.nightPayIncrease || 20}
onChange={(e) => setFormData({ ...formData, nightPayIncrease: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-night-pay-increase"
/>
</div>
<div className="space-y-2">
<Label htmlFor="overtimePayIncrease">Maggiorazione Straordinari (%)</Label>
<Input
id="overtimePayIncrease"
type="number"
value={formData.overtimePayIncrease || 15}
onChange={(e) => setFormData({ ...formData, overtimePayIncrease: parseInt(e.target.value) })}
disabled={!isEditing}
data-testid="input-overtime-pay-increase"
/>
</div>
</CardContent>
</Card>
{/* Tipo Contratto */}
<Card>
<CardHeader>
<CardTitle>Tipo Contratto</CardTitle>
<CardDescription>Identificatore CCNL di riferimento</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="contractType">CCNL di Riferimento</Label>
<Input
id="contractType"
value={formData.contractType || "CCNL_VIGILANZA_2024"}
onChange={(e) => setFormData({ ...formData, contractType: e.target.value })}
disabled={!isEditing}
data-testid="input-contract-type"
/>
</div>
</CardContent>
</Card>
</form>
</div>
);
}

View File

@ -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>
); );
} }

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-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",

View File

@ -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",

View File

@ -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 (!adminExists) { if (existingAdmin) {
// Crea utente admin di default // 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({ 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) {
return done(null, { id: admin.id, email: admin.email });
}
}
// Credenziali non valide if (!user) {
return done(null, false, { message: "Credenziali non valide" }); 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) { } 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) => {

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) => { 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,119 @@ 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" });
}
});
// ============= CONTRACT PARAMETERS ROUTES =============
app.get("/api/contract-parameters", isAuthenticated, async (req: any, res) => {
try {
const currentUserId = getUserId(req);
const currentUser = await storage.getUser(currentUserId);
// Only admins and coordinators can view parameters
if (currentUser?.role !== "admin" && currentUser?.role !== "coordinator") {
return res.status(403).json({ message: "Forbidden: Admin or Coordinator access required" });
}
let params = await storage.getContractParameters();
// Se non esistono parametri, creali con valori di default CCNL
if (!params) {
params = await storage.createContractParameters({
contractType: "CCNL_VIGILANZA_2024",
});
}
res.json(params);
} catch (error) {
console.error("Error fetching contract parameters:", error);
res.status(500).json({ message: "Failed to fetch contract parameters" });
}
});
app.put("/api/contract-parameters/:id", isAuthenticated, async (req: any, res) => {
try {
const currentUserId = getUserId(req);
const currentUser = await storage.getUser(currentUserId);
// Only admins can update parameters
if (currentUser?.role !== "admin") {
return res.status(403).json({ message: "Forbidden: Admin access required" });
}
// Validate request body with insert schema
const { insertContractParametersSchema } = await import("@shared/schema");
const validationResult = insertContractParametersSchema.partial().safeParse(req.body);
if (!validationResult.success) {
return res.status(400).json({
message: "Invalid parameters data",
errors: validationResult.error.errors
});
}
const updated = await storage.updateContractParameters(req.params.id, validationResult.data);
if (!updated) {
return res.status(404).json({ message: "Contract parameters not found" });
}
res.json(updated);
} catch (error) {
console.error("Error updating contract parameters:", error);
res.status(500).json({ message: "Failed to update contract parameters" });
}
});
// ============= CERTIFICATION ROUTES ============= // ============= CERTIFICATION ROUTES =============
app.post("/api/certifications", isAuthenticated, async (req, res) => { app.post("/api/certifications", isAuthenticated, async (req, res) => {
try { try {

View File

@ -3,6 +3,7 @@ import {
users, users,
guards, guards,
certifications, certifications,
vehicles,
sites, sites,
shifts, shifts,
shiftAssignments, shiftAssignments,
@ -14,12 +15,15 @@ import {
holidayAssignments, holidayAssignments,
absences, absences,
absenceAffectedShifts, absenceAffectedShifts,
contractParameters,
type User, type User,
type UpsertUser, type UpsertUser,
type Guard, type Guard,
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,
@ -42,6 +46,8 @@ import {
type InsertAbsence, type InsertAbsence,
type AbsenceAffectedShift, type AbsenceAffectedShift,
type InsertAbsenceAffectedShift, type InsertAbsenceAffectedShift,
type ContractParameters,
type InsertContractParameters,
} from "@shared/schema"; } from "@shared/schema";
import { db } from "./db"; import { db } from "./db";
import { eq, and, gte, lte, desc } from "drizzle-orm"; import { eq, and, gte, lte, desc } from "drizzle-orm";
@ -123,6 +129,11 @@ export interface IStorage {
getAffectedShiftsByAbsence(absenceId: string): Promise<AbsenceAffectedShift[]>; getAffectedShiftsByAbsence(absenceId: string): Promise<AbsenceAffectedShift[]>;
createAbsenceAffectedShift(affected: InsertAbsenceAffectedShift): Promise<AbsenceAffectedShift>; createAbsenceAffectedShift(affected: InsertAbsenceAffectedShift): Promise<AbsenceAffectedShift>;
deleteAbsenceAffectedShift(id: string): Promise<void>; deleteAbsenceAffectedShift(id: string): Promise<void>;
// Contract Parameters operations
getContractParameters(): Promise<ContractParameters | undefined>;
createContractParameters(params: InsertContractParameters): Promise<ContractParameters>;
updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined>;
} }
export class DatabaseStorage implements IStorage { export class DatabaseStorage implements IStorage {
@ -174,6 +185,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 +219,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
@ -510,6 +555,26 @@ export class DatabaseStorage implements IStorage {
async deleteAbsenceAffectedShift(id: string): Promise<void> { async deleteAbsenceAffectedShift(id: string): Promise<void> {
await db.delete(absenceAffectedShifts).where(eq(absenceAffectedShifts.id, id)); await db.delete(absenceAffectedShifts).where(eq(absenceAffectedShifts.id, id));
} }
// Contract Parameters operations
async getContractParameters(): Promise<ContractParameters | undefined> {
const params = await db.select().from(contractParameters).limit(1);
return params[0];
}
async createContractParameters(params: InsertContractParameters): Promise<ContractParameters> {
const [newParams] = await db.insert(contractParameters).values(params).returning();
return newParams;
}
async updateContractParameters(id: string, params: Partial<InsertContractParameters>): Promise<ContractParameters | undefined> {
const [updated] = await db
.update(contractParameters)
.set(params)
.where(eq(contractParameters.id, id))
.returning();
return updated;
}
} }
export const storage = new DatabaseStorage(); export const storage = new DatabaseStorage();

View File

@ -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", {
@ -253,8 +292,14 @@ export const contractParameters = pgTable("contract_parameters", {
// Riposi obbligatori // Riposi obbligatori
minDailyRestHours: integer("min_daily_rest_hours").notNull().default(11), minDailyRestHours: integer("min_daily_rest_hours").notNull().default(11),
minDailyRestHoursReduced: integer("min_daily_rest_hours_reduced").notNull().default(9), // Deroga CCNL
maxDailyRestReductionsPerMonth: integer("max_daily_rest_reductions_per_month").notNull().default(3),
maxDailyRestReductionsPerYear: integer("max_daily_rest_reductions_per_year").notNull().default(12),
minWeeklyRestHours: integer("min_weekly_rest_hours").notNull().default(24), minWeeklyRestHours: integer("min_weekly_rest_hours").notNull().default(24),
// Pause obbligatorie
pauseMinutesIfOver6Hours: integer("pause_minutes_if_over_6_hours").notNull().default(10),
// Limiti notturni (22:00-06:00) // Limiti notturni (22:00-06:00)
maxNightHoursPerWeek: integer("max_night_hours_per_week").default(48), maxNightHoursPerWeek: integer("max_night_hours_per_week").default(48),
@ -380,6 +425,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 +555,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 +589,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 +682,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;