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