Compare commits
4 Commits
09ff76e02d
...
743ec4261f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
743ec4261f | ||
|
|
dcb6a87ef8 | ||
|
|
b81f1253ac | ||
|
|
0203c9694d |
@ -9,6 +9,7 @@ import { AppSidebar } from "@/components/app-sidebar";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import Landing from "@/pages/landing";
|
||||
import Login from "@/pages/login";
|
||||
import Dashboard from "@/pages/dashboard";
|
||||
import Guards from "@/pages/guards";
|
||||
import Sites from "@/pages/sites";
|
||||
@ -17,12 +18,15 @@ import Reports from "@/pages/reports";
|
||||
import Notifications from "@/pages/notifications";
|
||||
import Users from "@/pages/users";
|
||||
import Planning from "@/pages/planning";
|
||||
import Vehicles from "@/pages/vehicles";
|
||||
import Parameters from "@/pages/parameters";
|
||||
|
||||
function Router() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Route path="/login" component={Login} />
|
||||
{isLoading || !isAuthenticated ? (
|
||||
<Route path="/" component={Landing} />
|
||||
) : (
|
||||
@ -30,11 +34,13 @@ function Router() {
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/guards" component={Guards} />
|
||||
<Route path="/sites" component={Sites} />
|
||||
<Route path="/vehicles" component={Vehicles} />
|
||||
<Route path="/shifts" component={Shifts} />
|
||||
<Route path="/planning" component={Planning} />
|
||||
<Route path="/reports" component={Reports} />
|
||||
<Route path="/notifications" component={Notifications} />
|
||||
<Route path="/users" component={Users} />
|
||||
<Route path="/parameters" component={Parameters} />
|
||||
</>
|
||||
)}
|
||||
<Route component={NotFound} />
|
||||
|
||||
@ -9,6 +9,7 @@ import {
|
||||
LogOut,
|
||||
UserCog,
|
||||
ClipboardList,
|
||||
Car,
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import {
|
||||
@ -59,6 +60,12 @@ const menuItems = [
|
||||
icon: MapPin,
|
||||
roles: ["admin", "coordinator", "client"],
|
||||
},
|
||||
{
|
||||
title: "Parco Automezzi",
|
||||
url: "/vehicles",
|
||||
icon: Car,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Report",
|
||||
url: "/reports",
|
||||
@ -77,6 +84,12 @@ const menuItems = [
|
||||
icon: UserCog,
|
||||
roles: ["admin"],
|
||||
},
|
||||
{
|
||||
title: "Parametri",
|
||||
url: "/parameters",
|
||||
icon: Settings,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
];
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
370
client/src/pages/parameters.tsx
Normal file
370
client/src/pages/parameters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { type User } from "@shared/schema";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { insertUserFormSchema, updateUserFormSchema, type User } from "@shared/schema";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
@ -17,6 +21,24 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@ -24,11 +46,21 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Shield, UserCog, Users as UsersIcon, UserCheck } from "lucide-react";
|
||||
import { Shield, UserCog, Users as UsersIcon, UserCheck, Plus, Pencil, Trash2, Loader2 } from "lucide-react";
|
||||
|
||||
const roleIcons = {
|
||||
admin: Shield,
|
||||
@ -51,35 +83,128 @@ const roleColors = {
|
||||
client: "bg-orange-500/10 text-orange-500 border-orange-500/20",
|
||||
};
|
||||
|
||||
type CreateUserForm = z.infer<typeof insertUserFormSchema>;
|
||||
type UpdateUserForm = z.infer<typeof updateUserFormSchema>;
|
||||
|
||||
export default function Users() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
|
||||
const { data: users, isLoading } = useQuery<User[]>({
|
||||
queryKey: ["/api/users"],
|
||||
});
|
||||
|
||||
const updateRoleMutation = useMutation({
|
||||
mutationFn: async ({ userId, role }: { userId: string; role: string }) => {
|
||||
return apiRequest("PATCH", `/api/users/${userId}`, { role });
|
||||
const createForm = useForm<CreateUserForm>({
|
||||
resolver: zodResolver(insertUserFormSchema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
password: "",
|
||||
role: "guard",
|
||||
},
|
||||
});
|
||||
|
||||
const editForm = useForm<UpdateUserForm>({
|
||||
resolver: zodResolver(updateUserFormSchema.required({ role: true })),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
password: "",
|
||||
role: "guard",
|
||||
},
|
||||
});
|
||||
|
||||
const createUserMutation = useMutation({
|
||||
mutationFn: async (data: CreateUserForm) => {
|
||||
return apiRequest("POST", "/api/users", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
toast({
|
||||
title: "Utente creato",
|
||||
description: "L'utente è stato creato con successo.",
|
||||
});
|
||||
setCreateDialogOpen(false);
|
||||
createForm.reset();
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Errore",
|
||||
description: error.message || "Impossibile creare l'utente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const updateUserMutation = useMutation({
|
||||
mutationFn: async ({ id, data }: { id: string; data: UpdateUserForm }) => {
|
||||
return apiRequest("PUT", `/api/users/${id}`, data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/auth/user"] });
|
||||
toast({
|
||||
title: "Ruolo aggiornato",
|
||||
description: "Il ruolo dell'utente è stato modificato con successo.",
|
||||
title: "Utente aggiornato",
|
||||
description: "L'utente è stato modificato con successo.",
|
||||
});
|
||||
setEditDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
editForm.reset();
|
||||
},
|
||||
onError: () => {
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Errore",
|
||||
description: "Impossibile aggiornare il ruolo dell'utente.",
|
||||
description: error.message || "Impossibile aggiornare l'utente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteUserMutation = useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
return apiRequest("DELETE", `/api/users/${id}`);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/users"] });
|
||||
toast({
|
||||
title: "Utente eliminato",
|
||||
description: "L'utente è stato eliminato con successo.",
|
||||
});
|
||||
setDeleteDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast({
|
||||
title: "Errore",
|
||||
description: "Impossibile eliminare l'utente.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleEdit = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
editForm.reset({
|
||||
email: user.email || "",
|
||||
firstName: user.firstName || "",
|
||||
lastName: user.lastName || "",
|
||||
password: "",
|
||||
role: user.role,
|
||||
});
|
||||
setEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (user: User) => {
|
||||
setSelectedUser(user);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@ -111,13 +236,19 @@ export default function Users() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold" data-testid="text-page-title">
|
||||
Gestione Utenti
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestisci utenti e permessi del sistema VigilanzaTurni
|
||||
</p>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold" data-testid="text-page-title">
|
||||
Gestione Utenti
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Gestisci utenti e permessi del sistema VigilanzaTurni
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setCreateDialogOpen(true)} data-testid="button-add-user">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Aggiungi Utente
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
@ -133,8 +264,8 @@ export default function Users() {
|
||||
<TableRow>
|
||||
<TableHead>Utente</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Ruolo Attuale</TableHead>
|
||||
<TableHead>Modifica Ruolo</TableHead>
|
||||
<TableHead>Ruolo</TableHead>
|
||||
<TableHead className="text-right">Azioni</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@ -181,45 +312,26 @@ export default function Users() {
|
||||
{roleLabels[user.role]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
value={user.role}
|
||||
onValueChange={(role) =>
|
||||
updateRoleMutation.mutate({ userId: user.id, role })
|
||||
}
|
||||
disabled={isCurrentUser || updateRoleMutation.isPending}
|
||||
data-testid={`select-role-${user.id}`}
|
||||
>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin" data-testid={`option-admin-${user.id}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="coordinator" data-testid={`option-coordinator-${user.id}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
Coordinatore
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="guard" data-testid={`option-guard-${user.id}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
Guardia
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="client" data-testid={`option-client-${user.id}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Cliente
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(user)}
|
||||
data-testid={`button-edit-${user.id}`}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(user)}
|
||||
disabled={isCurrentUser}
|
||||
data-testid={`button-delete-${user.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@ -234,6 +346,292 @@ export default function Users() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create User Dialog */}
|
||||
<Dialog open={createDialogOpen} onOpenChange={setCreateDialogOpen}>
|
||||
<DialogContent data-testid="dialog-create-user">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Crea Nuovo Utente</DialogTitle>
|
||||
<DialogDescription>
|
||||
Inserisci i dati del nuovo utente. La password deve essere di almeno 6 caratteri.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit((data) => createUserMutation.mutate(data))} className="space-y-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="utente@esempio.it" data-testid="input-create-email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nome</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Mario" data-testid="input-create-firstname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cognome</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Rossi" data-testid="input-create-lastname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="••••••••" data-testid="input-create-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ruolo</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value} data-testid="select-create-role">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="coordinator">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
Coordinatore
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="guard">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
Guardia
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="client">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Cliente
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setCreateDialogOpen(false)} data-testid="button-create-cancel">
|
||||
Annulla
|
||||
</Button>
|
||||
<Button type="submit" disabled={createUserMutation.isPending} data-testid="button-create-submit">
|
||||
{createUserMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creazione...
|
||||
</>
|
||||
) : (
|
||||
"Crea Utente"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit User Dialog */}
|
||||
<Dialog open={editDialogOpen} onOpenChange={setEditDialogOpen}>
|
||||
<DialogContent data-testid="dialog-edit-user">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Modifica Utente</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modifica i dati dell'utente. Lascia la password vuota per mantenerla invariata.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...editForm}>
|
||||
<form onSubmit={editForm.handleSubmit((data) => selectedUser && updateUserMutation.mutate({ id: selectedUser.id, data }))} className="space-y-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="email"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Email</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="email" placeholder="utente@esempio.it" data-testid="input-edit-email" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="firstName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Nome</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Mario" data-testid="input-edit-firstname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="lastName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Cognome</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Rossi" data-testid="input-edit-lastname" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Password (opzionale)</FormLabel>
|
||||
<FormControl>
|
||||
<Input type="password" placeholder="Lascia vuoto per non cambiare" data-testid="input-edit-password" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="role"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Ruolo</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value} data-testid="select-edit-role">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="admin">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4" />
|
||||
Admin
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="coordinator">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCog className="h-4 w-4" />
|
||||
Coordinatore
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="guard">
|
||||
<div className="flex items-center gap-2">
|
||||
<UsersIcon className="h-4 w-4" />
|
||||
Guardia
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="client">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserCheck className="h-4 w-4" />
|
||||
Cliente
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setEditDialogOpen(false)} data-testid="button-edit-cancel">
|
||||
Annulla
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateUserMutation.isPending} data-testid="button-edit-submit">
|
||||
{updateUserMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Salvataggio...
|
||||
</>
|
||||
) : (
|
||||
"Salva Modifiche"
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent data-testid="dialog-delete-user">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Conferma Eliminazione</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Sei sicuro di voler eliminare l'utente <strong>{selectedUser?.firstName} {selectedUser?.lastName}</strong>?
|
||||
Questa azione non può essere annullata.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel data-testid="button-delete-cancel">Annulla</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => selectedUser && deleteUserMutation.mutate(selectedUser.id)}
|
||||
className="bg-destructive hover:bg-destructive/90"
|
||||
data-testid="button-delete-confirm"
|
||||
>
|
||||
{deleteUserMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Eliminazione...
|
||||
</>
|
||||
) : (
|
||||
"Elimina"
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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-tooltip": "^1.2.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/memoizee": "^0.4.12",
|
||||
"@types/pg": "^8.15.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
@ -3421,6 +3423,15 @@
|
||||
"@babel/types": "^7.20.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/body-parser": {
|
||||
"version": "1.19.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz",
|
||||
@ -3853,6 +3864,20 @@
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bcrypt": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/binary-extensions": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -6078,12 +6103,20 @@
|
||||
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.3",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.3.tgz",
|
||||
"integrity": "sha512-EMS95CMJzdoSKoIiXo8pxKoL8DYxwIZXYlLmgPb8KUv794abpnLK6ynsCAWNliOjREKruYKdzbh76HHYUHX7nw==",
|
||||
"node_modules/node-addon-api": {
|
||||
"version": "8.5.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz",
|
||||
"integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"node_modules/node-gyp-build": {
|
||||
"version": "4.8.4",
|
||||
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
|
||||
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"node-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
|
||||
@ -42,8 +42,10 @@
|
||||
"@radix-ui/react-toggle-group": "^1.1.3",
|
||||
"@radix-ui/react-tooltip": "^1.2.0",
|
||||
"@tanstack/react-query": "^5.60.5",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/memoizee": "^0.4.12",
|
||||
"@types/pg": "^8.15.5",
|
||||
"bcrypt": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
|
||||
@ -34,18 +34,38 @@ export function getSession() {
|
||||
|
||||
async function initDefaultAdmin() {
|
||||
try {
|
||||
// Verifica se esiste già un admin
|
||||
const bcrypt = await import("bcrypt");
|
||||
const users = await storage.getAllUsers();
|
||||
const adminExists = users.some((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
|
||||
const existingAdmin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
|
||||
|
||||
if (!adminExists) {
|
||||
// Crea utente admin di default
|
||||
if (existingAdmin) {
|
||||
// Admin esiste: controlla se ha passwordHash
|
||||
if (!existingAdmin.passwordHash) {
|
||||
console.log(`🔄 [LocalAuth] Aggiornamento password hash per admin esistente...`);
|
||||
const passwordHash = await bcrypt.hash(DEFAULT_ADMIN_PASSWORD, 10);
|
||||
|
||||
await storage.upsertUser({
|
||||
id: existingAdmin.id,
|
||||
email: existingAdmin.email,
|
||||
firstName: existingAdmin.firstName || "Admin",
|
||||
lastName: existingAdmin.lastName || "Sistema",
|
||||
profileImageUrl: existingAdmin.profileImageUrl,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
console.log(`✅ [LocalAuth] Password hash aggiornato per: ${DEFAULT_ADMIN_EMAIL}`);
|
||||
}
|
||||
} else {
|
||||
// Admin non esiste: crealo
|
||||
const passwordHash = await bcrypt.hash(DEFAULT_ADMIN_PASSWORD, 10);
|
||||
|
||||
await storage.upsertUser({
|
||||
id: DEFAULT_ADMIN_ID,
|
||||
email: DEFAULT_ADMIN_EMAIL,
|
||||
firstName: "Admin",
|
||||
lastName: "Sistema",
|
||||
profileImageUrl: null,
|
||||
passwordHash,
|
||||
});
|
||||
|
||||
// Imposta ruolo admin
|
||||
@ -67,23 +87,31 @@ export async function setupLocalAuth(app: Express) {
|
||||
// Inizializza admin di default
|
||||
await initDefaultAdmin();
|
||||
|
||||
// Strategia passport-local
|
||||
// Strategia passport-local con password hash bcrypt
|
||||
passport.use(new LocalStrategy(
|
||||
{ usernameField: "email" },
|
||||
async (email, password, done) => {
|
||||
try {
|
||||
// Per demo: accetta credenziali admin di default
|
||||
if (email === DEFAULT_ADMIN_EMAIL && password === DEFAULT_ADMIN_PASSWORD) {
|
||||
const users = await storage.getAllUsers();
|
||||
const admin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
|
||||
|
||||
if (admin) {
|
||||
return done(null, { id: admin.id, email: admin.email });
|
||||
}
|
||||
}
|
||||
const users = await storage.getAllUsers();
|
||||
const user = users.find((u: any) => u.email === email);
|
||||
|
||||
// Credenziali non valide
|
||||
return done(null, false, { message: "Credenziali non valide" });
|
||||
if (!user) {
|
||||
return done(null, false, { message: "Credenziali non valide" });
|
||||
}
|
||||
|
||||
if (!user.passwordHash) {
|
||||
return done(null, false, { message: "Credenziali non valide" });
|
||||
}
|
||||
|
||||
// Verifica password con bcrypt
|
||||
const bcrypt = await import("bcrypt");
|
||||
const isValidPassword = await bcrypt.compare(password, user.passwordHash);
|
||||
|
||||
if (!isValidPassword) {
|
||||
return done(null, false, { message: "Credenziali non valide" });
|
||||
}
|
||||
|
||||
return done(null, { id: user.id, email: user.email });
|
||||
} catch (error) {
|
||||
return done(error);
|
||||
}
|
||||
@ -104,10 +132,9 @@ export async function setupLocalAuth(app: Express) {
|
||||
}
|
||||
});
|
||||
|
||||
// Route login GET (redirect auto-login per compatibilità)
|
||||
// Route login GET (redirect a pagina login frontend)
|
||||
app.get("/api/login", (req, res) => {
|
||||
// Redirect a auto-login admin per demo
|
||||
res.redirect("/api/auto-login-admin");
|
||||
res.redirect("/login");
|
||||
});
|
||||
|
||||
// Route login locale POST
|
||||
@ -119,42 +146,6 @@ export async function setupLocalAuth(app: Express) {
|
||||
});
|
||||
});
|
||||
|
||||
// Route auto-login admin (solo per demo/sviluppo)
|
||||
app.get("/api/auto-login-admin", async (req, res) => {
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
console.warn("⚠️ [LocalAuth] Auto-login admin attivato (solo sviluppo!)");
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("🔍 [LocalAuth] Recupero lista utenti...");
|
||||
const users = await storage.getAllUsers();
|
||||
console.log(`✅ [LocalAuth] Trovati ${users.length} utenti`);
|
||||
|
||||
const admin = users.find((u: any) => u.email === DEFAULT_ADMIN_EMAIL);
|
||||
|
||||
if (admin) {
|
||||
console.log(`✅ [LocalAuth] Admin trovato: ${admin.email}`);
|
||||
req.login({ id: admin.id, email: admin.email }, (err) => {
|
||||
if (err) {
|
||||
console.error("❌ [LocalAuth] Errore req.login:", err);
|
||||
return res.status(500).json({ error: "Errore auto-login", details: err.message });
|
||||
}
|
||||
console.log("✅ [LocalAuth] Login effettuato, redirect a /");
|
||||
res.redirect("/");
|
||||
});
|
||||
} else {
|
||||
console.error(`❌ [LocalAuth] Admin non trovato (cercato: ${DEFAULT_ADMIN_EMAIL})`);
|
||||
res.status(404).json({ error: "Admin non trovato", users: users.map((u: any) => u.email) });
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("❌ [LocalAuth] Errore in auto-login-admin:", error);
|
||||
res.status(500).json({
|
||||
error: "Errore server",
|
||||
message: error.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Route logout
|
||||
app.get("/api/logout", (req, res) => {
|
||||
@ -164,9 +155,9 @@ export async function setupLocalAuth(app: Express) {
|
||||
});
|
||||
|
||||
console.log("✅ [LocalAuth] Sistema autenticazione locale attivato");
|
||||
console.log(`📧 Email admin: ${DEFAULT_ADMIN_EMAIL}`);
|
||||
console.log(`🔑 Password admin: ${DEFAULT_ADMIN_PASSWORD}`);
|
||||
console.log(`🔗 Auto-login: GET /api/auto-login-admin`);
|
||||
console.log(`📧 Admin email: ${DEFAULT_ADMIN_EMAIL}`);
|
||||
console.log(`🔑 Admin password: ${DEFAULT_ADMIN_PASSWORD}`);
|
||||
console.log(`🔗 Login page: /login`);
|
||||
}
|
||||
|
||||
export const isAuthenticated = async (req: any, res: any, next: any) => {
|
||||
|
||||
233
server/routes.ts
233
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) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
@ -95,6 +143,78 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/api/users/:id", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
const currentUser = await storage.getUser(currentUserId);
|
||||
|
||||
// Only admins can update users
|
||||
if (currentUser?.role !== "admin") {
|
||||
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
||||
}
|
||||
|
||||
const { email, firstName, lastName, password, role } = req.body;
|
||||
const existingUser = await storage.getUser(req.params.id);
|
||||
|
||||
if (!existingUser) {
|
||||
return res.status(404).json({ message: "User not found" });
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData: any = {
|
||||
id: req.params.id,
|
||||
email: email || existingUser.email,
|
||||
firstName: firstName || existingUser.firstName,
|
||||
lastName: lastName || existingUser.lastName,
|
||||
profileImageUrl: existingUser.profileImageUrl,
|
||||
};
|
||||
|
||||
// Hash new password if provided
|
||||
if (password) {
|
||||
const bcrypt = await import("bcrypt");
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updated = await storage.upsertUser(updateData);
|
||||
|
||||
// Update role if provided and not changing own role
|
||||
if (role && req.params.id !== currentUserId) {
|
||||
await storage.updateUserRole(req.params.id, role);
|
||||
}
|
||||
|
||||
res.json(updated);
|
||||
} catch (error: any) {
|
||||
console.error("Error updating user:", error);
|
||||
if (error.code === '23505') {
|
||||
return res.status(409).json({ message: "Email già esistente" });
|
||||
}
|
||||
res.status(500).json({ message: "Failed to update user" });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/users/:id", isAuthenticated, async (req: any, res) => {
|
||||
try {
|
||||
const currentUserId = getUserId(req);
|
||||
const currentUser = await storage.getUser(currentUserId);
|
||||
|
||||
// Only admins can delete users
|
||||
if (currentUser?.role !== "admin") {
|
||||
return res.status(403).json({ message: "Forbidden: Admin access required" });
|
||||
}
|
||||
|
||||
// Prevent admins from deleting themselves
|
||||
if (req.params.id === currentUserId) {
|
||||
return res.status(403).json({ message: "Cannot delete your own account" });
|
||||
}
|
||||
|
||||
await storage.deleteUser(req.params.id);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("Error deleting user:", error);
|
||||
res.status(500).json({ message: "Failed to delete user" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= GUARD ROUTES =============
|
||||
app.get("/api/guards", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
@ -174,6 +294,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 =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -3,6 +3,7 @@ import {
|
||||
users,
|
||||
guards,
|
||||
certifications,
|
||||
vehicles,
|
||||
sites,
|
||||
shifts,
|
||||
shiftAssignments,
|
||||
@ -14,12 +15,15 @@ import {
|
||||
holidayAssignments,
|
||||
absences,
|
||||
absenceAffectedShifts,
|
||||
contractParameters,
|
||||
type User,
|
||||
type UpsertUser,
|
||||
type Guard,
|
||||
type InsertGuard,
|
||||
type Certification,
|
||||
type InsertCertification,
|
||||
type Vehicle,
|
||||
type InsertVehicle,
|
||||
type Site,
|
||||
type InsertSite,
|
||||
type Shift,
|
||||
@ -42,6 +46,8 @@ import {
|
||||
type InsertAbsence,
|
||||
type AbsenceAffectedShift,
|
||||
type InsertAbsenceAffectedShift,
|
||||
type ContractParameters,
|
||||
type InsertContractParameters,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc } from "drizzle-orm";
|
||||
@ -123,6 +129,11 @@ export interface IStorage {
|
||||
getAffectedShiftsByAbsence(absenceId: string): Promise<AbsenceAffectedShift[]>;
|
||||
createAbsenceAffectedShift(affected: InsertAbsenceAffectedShift): Promise<AbsenceAffectedShift>;
|
||||
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 {
|
||||
@ -174,6 +185,11 @@ export class DatabaseStorage implements IStorage {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteUser(id: string): Promise<User | undefined> {
|
||||
const [deleted] = await db.delete(users).where(eq(users.id, id)).returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Guard operations
|
||||
async getAllGuards(): Promise<Guard[]> {
|
||||
return await db.select().from(guards);
|
||||
@ -203,6 +219,35 @@ export class DatabaseStorage implements IStorage {
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Vehicle operations
|
||||
async getAllVehicles(): Promise<Vehicle[]> {
|
||||
return await db.select().from(vehicles).orderBy(desc(vehicles.createdAt));
|
||||
}
|
||||
|
||||
async getVehicle(id: string): Promise<Vehicle | undefined> {
|
||||
const [vehicle] = await db.select().from(vehicles).where(eq(vehicles.id, id));
|
||||
return vehicle;
|
||||
}
|
||||
|
||||
async createVehicle(vehicle: InsertVehicle): Promise<Vehicle> {
|
||||
const [newVehicle] = await db.insert(vehicles).values(vehicle).returning();
|
||||
return newVehicle;
|
||||
}
|
||||
|
||||
async updateVehicle(id: string, vehicleData: Partial<InsertVehicle>): Promise<Vehicle | undefined> {
|
||||
const [updated] = await db
|
||||
.update(vehicles)
|
||||
.set({ ...vehicleData, updatedAt: new Date() })
|
||||
.where(eq(vehicles.id, id))
|
||||
.returning();
|
||||
return updated;
|
||||
}
|
||||
|
||||
async deleteVehicle(id: string): Promise<Vehicle | undefined> {
|
||||
const [deleted] = await db.delete(vehicles).where(eq(vehicles.id, id)).returning();
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// Certification operations
|
||||
async getCertificationsByGuard(guardId: string): Promise<Certification[]> {
|
||||
return await db
|
||||
@ -510,6 +555,26 @@ export class DatabaseStorage implements IStorage {
|
||||
async deleteAbsenceAffectedShift(id: string): Promise<void> {
|
||||
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();
|
||||
|
||||
@ -71,6 +71,20 @@ export const sitePreferenceTypeEnum = pgEnum("site_preference_type", [
|
||||
"blacklisted", // Non assegnare mai questo operatore a questo sito
|
||||
]);
|
||||
|
||||
export const vehicleStatusEnum = pgEnum("vehicle_status", [
|
||||
"available", // Disponibile
|
||||
"in_use", // In uso
|
||||
"maintenance", // In manutenzione
|
||||
"out_of_service", // Fuori servizio
|
||||
]);
|
||||
|
||||
export const vehicleTypeEnum = pgEnum("vehicle_type", [
|
||||
"car", // Auto
|
||||
"van", // Furgone
|
||||
"motorcycle", // Moto
|
||||
"suv", // SUV
|
||||
]);
|
||||
|
||||
// ============= SESSION & AUTH TABLES (Replit Auth) =============
|
||||
|
||||
// Session storage table - mandatory for Replit Auth
|
||||
@ -91,6 +105,7 @@ export const users = pgTable("users", {
|
||||
firstName: varchar("first_name"),
|
||||
lastName: varchar("last_name"),
|
||||
profileImageUrl: varchar("profile_image_url"),
|
||||
passwordHash: varchar("password_hash"), // For local auth - bcrypt hash
|
||||
role: userRoleEnum("role").notNull().default("guard"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
@ -127,6 +142,30 @@ export const certifications = pgTable("certifications", {
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
});
|
||||
|
||||
// ============= VEHICLES (PARCO AUTOMEZZI) =============
|
||||
|
||||
export const vehicles = pgTable("vehicles", {
|
||||
id: varchar("id").primaryKey().default(sql`gen_random_uuid()`),
|
||||
licensePlate: varchar("license_plate").notNull().unique(), // Targa
|
||||
brand: varchar("brand").notNull(), // Marca (es: Fiat, Volkswagen)
|
||||
model: varchar("model").notNull(), // Modello
|
||||
vehicleType: vehicleTypeEnum("vehicle_type").notNull(),
|
||||
year: integer("year"), // Anno immatricolazione
|
||||
|
||||
// Assegnazione
|
||||
assignedGuardId: varchar("assigned_guard_id").references(() => guards.id, { onDelete: "set null" }),
|
||||
|
||||
// Stato e manutenzione
|
||||
status: vehicleStatusEnum("status").notNull().default("available"),
|
||||
lastMaintenanceDate: date("last_maintenance_date"),
|
||||
nextMaintenanceDate: date("next_maintenance_date"),
|
||||
mileage: integer("mileage"), // Chilometraggio
|
||||
|
||||
notes: text("notes"),
|
||||
createdAt: timestamp("created_at").defaultNow(),
|
||||
updatedAt: timestamp("updated_at").defaultNow(),
|
||||
});
|
||||
|
||||
// ============= SITES & CONTRACTS =============
|
||||
|
||||
export const sites = pgTable("sites", {
|
||||
@ -253,8 +292,14 @@ export const contractParameters = pgTable("contract_parameters", {
|
||||
|
||||
// Riposi obbligatori
|
||||
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),
|
||||
|
||||
// Pause obbligatorie
|
||||
pauseMinutesIfOver6Hours: integer("pause_minutes_if_over_6_hours").notNull().default(10),
|
||||
|
||||
// Limiti notturni (22:00-06:00)
|
||||
maxNightHoursPerWeek: integer("max_night_hours_per_week").default(48),
|
||||
|
||||
@ -380,6 +425,14 @@ export const guardsRelations = relations(guards, ({ one, many }) => ({
|
||||
sitePreferences: many(sitePreferences),
|
||||
trainingCourses: many(trainingCourses),
|
||||
absences: many(absences),
|
||||
assignedVehicles: many(vehicles),
|
||||
}));
|
||||
|
||||
export const vehiclesRelations = relations(vehicles, ({ one }) => ({
|
||||
assignedGuard: one(guards, {
|
||||
fields: [vehicles.assignedGuardId],
|
||||
references: [guards.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const certificationsRelations = relations(certifications, ({ one }) => ({
|
||||
@ -502,9 +555,28 @@ export const insertUserSchema = createInsertSchema(users).pick({
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
profileImageUrl: true,
|
||||
passwordHash: true,
|
||||
role: true,
|
||||
});
|
||||
|
||||
// Form schema with plain password (will be hashed on backend)
|
||||
export const insertUserFormSchema = z.object({
|
||||
email: z.string().email("Email non valida"),
|
||||
firstName: z.string().min(1, "Nome obbligatorio"),
|
||||
lastName: z.string().min(1, "Cognome obbligatorio"),
|
||||
password: z.string().min(6, "Password minimo 6 caratteri"),
|
||||
role: z.enum(["admin", "coordinator", "guard", "client"]).default("guard"),
|
||||
});
|
||||
|
||||
// Update user form schema (password optional)
|
||||
export const updateUserFormSchema = z.object({
|
||||
email: z.string().email("Email non valida").optional(),
|
||||
firstName: z.string().min(1, "Nome obbligatorio").optional(),
|
||||
lastName: z.string().min(1, "Cognome obbligatorio").optional(),
|
||||
password: z.string().min(6, "Password minimo 6 caratteri").optional(),
|
||||
role: z.enum(["admin", "coordinator", "guard", "client"]).optional(),
|
||||
});
|
||||
|
||||
export const insertGuardSchema = createInsertSchema(guards).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@ -517,6 +589,12 @@ export const insertCertificationSchema = createInsertSchema(certifications).omit
|
||||
status: true,
|
||||
});
|
||||
|
||||
export const insertVehicleSchema = createInsertSchema(vehicles).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
});
|
||||
|
||||
export const insertSiteSchema = createInsertSchema(sites).omit({
|
||||
id: true,
|
||||
createdAt: true,
|
||||
@ -604,6 +682,9 @@ export type Guard = typeof guards.$inferSelect;
|
||||
export type InsertCertification = z.infer<typeof insertCertificationSchema>;
|
||||
export type Certification = typeof certifications.$inferSelect;
|
||||
|
||||
export type InsertVehicle = z.infer<typeof insertVehicleSchema>;
|
||||
export type Vehicle = typeof vehicles.$inferSelect;
|
||||
|
||||
export type InsertSite = z.infer<typeof insertSiteSchema>;
|
||||
export type Site = typeof sites.$inferSelect;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user