Compare commits
6 Commits
47fa3104e3
...
dd468716d9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd468716d9 | ||
|
|
fdbcc3eee3 | ||
|
|
7ab5ae65a7 | ||
|
|
5c6bf0ca4d | ||
|
|
500da807cf | ||
|
|
8237234fad |
@ -17,9 +17,11 @@ import Shifts from "@/pages/shifts";
|
||||
import Reports from "@/pages/reports";
|
||||
import Notifications from "@/pages/notifications";
|
||||
import Users from "@/pages/users";
|
||||
import Planning from "@/pages/planning";
|
||||
import AdvancedPlanning from "@/pages/advanced-planning";
|
||||
import Vehicles from "@/pages/vehicles";
|
||||
import Parameters from "@/pages/parameters";
|
||||
import Services from "@/pages/services";
|
||||
import Planning from "@/pages/planning";
|
||||
|
||||
function Router() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@ -34,9 +36,11 @@ function Router() {
|
||||
<Route path="/" component={Dashboard} />
|
||||
<Route path="/guards" component={Guards} />
|
||||
<Route path="/sites" component={Sites} />
|
||||
<Route path="/services" component={Services} />
|
||||
<Route path="/vehicles" component={Vehicles} />
|
||||
<Route path="/shifts" component={Shifts} />
|
||||
<Route path="/planning" component={Planning} />
|
||||
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
||||
<Route path="/reports" component={Reports} />
|
||||
<Route path="/notifications" component={Notifications} />
|
||||
<Route path="/users" component={Users} />
|
||||
|
||||
@ -10,6 +10,7 @@ import {
|
||||
UserCog,
|
||||
ClipboardList,
|
||||
Car,
|
||||
Briefcase,
|
||||
} from "lucide-react";
|
||||
import { Link, useLocation } from "wouter";
|
||||
import {
|
||||
@ -48,6 +49,12 @@ const menuItems = [
|
||||
icon: ClipboardList,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Pianificazione Avanzata",
|
||||
url: "/advanced-planning",
|
||||
icon: ClipboardList,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Guardie",
|
||||
url: "/guards",
|
||||
@ -60,6 +67,12 @@ const menuItems = [
|
||||
icon: MapPin,
|
||||
roles: ["admin", "coordinator", "client"],
|
||||
},
|
||||
{
|
||||
title: "Servizi",
|
||||
url: "/services",
|
||||
icon: Briefcase,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Parco Automezzi",
|
||||
url: "/vehicles",
|
||||
|
||||
480
client/src/pages/advanced-planning.tsx
Normal file
480
client/src/pages/advanced-planning.tsx
Normal file
@ -0,0 +1,480 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Calendar, GraduationCap, HeartPulse, PartyPopper, Plus, Trash2 } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
|
||||
export default function PlanningPage() {
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState("training");
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Pianificazione Avanzata</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Gestisci formazione, assenze e festività per ottimizzare la pianificazione turni
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="training" data-testid="tab-training">
|
||||
<GraduationCap className="h-4 w-4 mr-2" />
|
||||
Formazione
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="absences" data-testid="tab-absences">
|
||||
<HeartPulse className="h-4 w-4 mr-2" />
|
||||
Assenze
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="holidays" data-testid="tab-holidays">
|
||||
<PartyPopper className="h-4 w-4 mr-2" />
|
||||
Festività
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="training">
|
||||
<TrainingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="absences">
|
||||
<AbsencesTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="holidays">
|
||||
<HolidaysTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrainingTab() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const { data: courses = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/training-courses"],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("/api/training-courses", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/training-courses"] });
|
||||
toast({ title: "Corso creato con successo" });
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest(`/api/training-courses/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/training-courses"] });
|
||||
toast({ title: "Corso eliminato" });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Caricamento corsi...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-course">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuovo Corso
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuovo Corso di Formazione</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
createMutation.mutate({
|
||||
guardId: formData.get("guardId"),
|
||||
courseName: formData.get("courseName"),
|
||||
courseType: formData.get("courseType"),
|
||||
scheduledDate: formData.get("scheduledDate"),
|
||||
status: "scheduled",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="courseName">Nome Corso</Label>
|
||||
<Input id="courseName" name="courseName" required data-testid="input-course-name" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="courseType">Tipo</Label>
|
||||
<Select name="courseType" required>
|
||||
<SelectTrigger data-testid="select-course-type">
|
||||
<SelectValue placeholder="Seleziona tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mandatory">Obbligatorio</SelectItem>
|
||||
<SelectItem value="optional">Facoltativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="guardId">ID Guardia</Label>
|
||||
<Input id="guardId" name="guardId" required data-testid="input-guard-id" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="scheduledDate">Data Programmata</Label>
|
||||
<Input id="scheduledDate" name="scheduledDate" type="date" required data-testid="input-scheduled-date" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-course">
|
||||
{createMutation.isPending ? "Creazione..." : "Crea Corso"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{courses.map((course: any) => (
|
||||
<Card key={course.id} data-testid={`card-course-${course.id}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{course.courseName}</CardTitle>
|
||||
<CardDescription>
|
||||
Programmato: {course.scheduledDate ? format(new Date(course.scheduledDate), "dd MMM yyyy", { locale: it }) : "N/D"}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={course.courseType === "mandatory" ? "default" : "secondary"}>
|
||||
{course.courseType === "mandatory" ? "Obbligatorio" : "Facoltativo"}
|
||||
</Badge>
|
||||
<Badge variant={course.status === "completed" ? "default" : "secondary"}>
|
||||
{course.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(course.id)}
|
||||
data-testid={`button-delete-course-${course.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{courses.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nessun corso di formazione programmato
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbsencesTab() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const { data: absences = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/absences"],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("/api/absences", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/absences"] });
|
||||
toast({ title: "Assenza registrata" });
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest(`/api/absences/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/absences"] });
|
||||
toast({ title: "Assenza eliminata" });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Caricamento assenze...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-absence">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuova Assenza
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Registra Assenza</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
createMutation.mutate({
|
||||
guardId: formData.get("guardId"),
|
||||
type: formData.get("type"),
|
||||
startDate: formData.get("startDate"),
|
||||
endDate: formData.get("endDate"),
|
||||
notes: formData.get("notes"),
|
||||
isApproved: false,
|
||||
needsSubstitute: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="guardId">ID Guardia</Label>
|
||||
<Input id="guardId" name="guardId" required data-testid="input-absence-guard-id" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">Tipo Assenza</Label>
|
||||
<Select name="type" required>
|
||||
<SelectTrigger data-testid="select-absence-type">
|
||||
<SelectValue placeholder="Seleziona tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sick_leave">Malattia</SelectItem>
|
||||
<SelectItem value="vacation">Ferie</SelectItem>
|
||||
<SelectItem value="personal_leave">Permesso</SelectItem>
|
||||
<SelectItem value="injury">Infortunio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="startDate">Data Inizio</Label>
|
||||
<Input id="startDate" name="startDate" type="date" required data-testid="input-start-date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="endDate">Data Fine</Label>
|
||||
<Input id="endDate" name="endDate" type="date" required data-testid="input-end-date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notes">Note</Label>
|
||||
<Input id="notes" name="notes" data-testid="input-absence-notes" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-absence">
|
||||
{createMutation.isPending ? "Registrazione..." : "Registra Assenza"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{absences.map((absence: any) => (
|
||||
<Card key={absence.id} data-testid={`card-absence-${absence.id}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{absence.type === "sick_leave" && "Malattia"}
|
||||
{absence.type === "vacation" && "Ferie"}
|
||||
{absence.type === "personal_leave" && "Permesso"}
|
||||
{absence.type === "injury" && "Infortunio"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Dal {format(new Date(absence.startDate), "dd MMM", { locale: it })} al{" "}
|
||||
{format(new Date(absence.endDate), "dd MMM yyyy", { locale: it })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={absence.isApproved ? "default" : "secondary"}>
|
||||
{absence.isApproved ? "Approvata" : "In attesa"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(absence.id)}
|
||||
data-testid={`button-delete-absence-${absence.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{absences.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nessuna assenza registrata
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HolidaysTab() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const { data: holidays = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/holidays", currentYear],
|
||||
queryFn: () => fetch(`/api/holidays?year=${currentYear}`).then((r) => r.json()),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("/api/holidays", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/holidays", currentYear] });
|
||||
toast({ title: "Festività creata" });
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest(`/api/holidays/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/holidays", currentYear] });
|
||||
toast({ title: "Festività eliminata" });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Caricamento festività...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-holiday">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuova Festività
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuova Festività</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const dateValue = formData.get("date") as string;
|
||||
createMutation.mutate({
|
||||
name: formData.get("name"),
|
||||
date: dateValue,
|
||||
year: new Date(dateValue).getFullYear(),
|
||||
isNational: formData.get("isNational") === "true",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nome Festività</Label>
|
||||
<Input id="name" name="name" required data-testid="input-holiday-name" placeholder="es. Natale, 1° Maggio..." />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="date">Data</Label>
|
||||
<Input id="date" name="date" type="date" required data-testid="input-holiday-date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="isNational">Tipo</Label>
|
||||
<Select name="isNational" defaultValue="true">
|
||||
<SelectTrigger data-testid="select-holiday-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Nazionale</SelectItem>
|
||||
<SelectItem value="false">Regionale</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-holiday">
|
||||
{createMutation.isPending ? "Creazione..." : "Crea Festività"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{holidays.map((holiday: any) => (
|
||||
<Card key={holiday.id} data-testid={`card-holiday-${holiday.id}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{holiday.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{format(new Date(holiday.date), "EEEE dd MMMM yyyy", { locale: it })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={holiday.isNational ? "default" : "secondary"}>
|
||||
{holiday.isNational ? "Nazionale" : "Regionale"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(holiday.id)}
|
||||
data-testid={`button-delete-holiday-${holiday.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{holidays.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nessuna festività configurata per {currentYear}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -10,6 +10,7 @@ import { ShiftWithDetails, GuardWithCertifications, Site } from "@shared/schema"
|
||||
import { formatDistanceToNow, format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import versionInfo from "../../../version.json";
|
||||
|
||||
export default function Dashboard() {
|
||||
const { user } = useAuth();
|
||||
@ -67,6 +68,9 @@ export default function Dashboard() {
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold mb-2">Dashboard Operativa</h1>
|
||||
<p className="text-sm text-muted-foreground/70 mb-2" data-testid="text-version">
|
||||
v{versionInfo.version}
|
||||
</p>
|
||||
<p className="text-muted-foreground">
|
||||
Benvenuto, {user?.firstName} {user?.lastName}
|
||||
</p>
|
||||
|
||||
@ -1,480 +1,270 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import { Calendar, GraduationCap, HeartPulse, PartyPopper, Plus, Trash2 } from "lucide-react";
|
||||
import { Vehicle, Site, ShiftWithDetails } from "@shared/schema";
|
||||
import { Building2, Car, MapPin, Calendar } from "lucide-react";
|
||||
import { format } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
|
||||
export default function PlanningPage() {
|
||||
const { toast } = useToast();
|
||||
const [activeTab, setActiveTab] = useState("training");
|
||||
const locationLabels = {
|
||||
all: "Tutte le Sedi",
|
||||
roccapiemonte: "Roccapiemonte",
|
||||
milano: "Milano",
|
||||
roma: "Roma"
|
||||
} as const;
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
export default function Planning() {
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>("all");
|
||||
|
||||
const { data: vehicles = [], isLoading: loadingVehicles } = useQuery<Vehicle[]>({
|
||||
queryKey: ["/api/vehicles"],
|
||||
});
|
||||
|
||||
const { data: sites = [], isLoading: loadingSites } = useQuery<Site[]>({
|
||||
queryKey: ["/api/sites"],
|
||||
});
|
||||
|
||||
const { data: shifts = [], isLoading: loadingShifts } = useQuery<ShiftWithDetails[]>({
|
||||
queryKey: ["/api/shifts/active"],
|
||||
});
|
||||
|
||||
// Filter by location
|
||||
const filteredVehicles = selectedLocation === "all"
|
||||
? vehicles
|
||||
: vehicles.filter(v => v.location === selectedLocation);
|
||||
|
||||
const filteredSites = selectedLocation === "all"
|
||||
? sites
|
||||
: sites.filter(s => s.location === selectedLocation);
|
||||
|
||||
const filteredShifts = selectedLocation === "all"
|
||||
? shifts
|
||||
: shifts.filter(s => {
|
||||
const site = sites.find(site => site.id === s.siteId);
|
||||
return site?.location === selectedLocation;
|
||||
});
|
||||
|
||||
const isLoading = loadingVehicles || loadingSites || loadingShifts;
|
||||
|
||||
// Calculate stats
|
||||
const availableVehicles = filteredVehicles.filter(v => v.status === "available").length;
|
||||
const inUseVehicles = filteredVehicles.filter(v => v.status === "in_use").length;
|
||||
const activeSites = filteredSites.filter(s => s.isActive).length;
|
||||
const activeShifts = filteredShifts.filter(s => s.status === "active").length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Pianificazione Avanzata</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Gestisci formazione, assenze e festività per ottimizzare la pianificazione turni
|
||||
<h1 className="text-3xl font-semibold mb-2">Pianificazione Operativa</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Vista per sede con gestione automezzi e turni attivi
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Building2 className="h-5 w-5 text-muted-foreground" />
|
||||
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
|
||||
<SelectTrigger className="w-[200px]" data-testid="select-location">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">Tutte le Sedi</SelectItem>
|
||||
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||
<SelectItem value="milano">Milano</SelectItem>
|
||||
<SelectItem value="roma">Roma</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="training" data-testid="tab-training">
|
||||
<GraduationCap className="h-4 w-4 mr-2" />
|
||||
Formazione
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="absences" data-testid="tab-absences">
|
||||
<HeartPulse className="h-4 w-4 mr-2" />
|
||||
Assenze
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="holidays" data-testid="tab-holidays">
|
||||
<PartyPopper className="h-4 w-4 mr-2" />
|
||||
Festività
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Caricamento dati...</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Stats Summary */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Turni Attivi</CardTitle>
|
||||
<Calendar className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold" data-testid="text-active-shifts">{activeShifts}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{locationLabels[selectedLocation as keyof typeof locationLabels]}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Siti Attivi</CardTitle>
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold" data-testid="text-active-sites">{activeSites}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
su {filteredSites.length} totali
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<TabsContent value="training">
|
||||
<TrainingTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="absences">
|
||||
<AbsencesTab />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="holidays">
|
||||
<HolidaysTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrainingTab() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const { data: courses = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/training-courses"],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("/api/training-courses", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/training-courses"] });
|
||||
toast({ title: "Corso creato con successo" });
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest(`/api/training-courses/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/training-courses"] });
|
||||
toast({ title: "Corso eliminato" });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Caricamento corsi...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-course">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuovo Corso
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuovo Corso di Formazione</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
createMutation.mutate({
|
||||
guardId: formData.get("guardId"),
|
||||
courseName: formData.get("courseName"),
|
||||
courseType: formData.get("courseType"),
|
||||
scheduledDate: formData.get("scheduledDate"),
|
||||
status: "scheduled",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="courseName">Nome Corso</Label>
|
||||
<Input id="courseName" name="courseName" required data-testid="input-course-name" />
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Automezzi Disponibili</CardTitle>
|
||||
<Car className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-500" data-testid="text-available-vehicles">
|
||||
{availableVehicles}
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="courseType">Tipo</Label>
|
||||
<Select name="courseType" required>
|
||||
<SelectTrigger data-testid="select-course-type">
|
||||
<SelectValue placeholder="Seleziona tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="mandatory">Obbligatorio</SelectItem>
|
||||
<SelectItem value="optional">Facoltativo</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="guardId">ID Guardia</Label>
|
||||
<Input id="guardId" name="guardId" required data-testid="input-guard-id" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="scheduledDate">Data Programmata</Label>
|
||||
<Input id="scheduledDate" name="scheduledDate" type="date" required data-testid="input-scheduled-date" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-course">
|
||||
{createMutation.isPending ? "Creazione..." : "Crea Corso"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
su {filteredVehicles.length} totali
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{courses.map((course: any) => (
|
||||
<Card key={course.id} data-testid={`card-course-${course.id}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{course.courseName}</CardTitle>
|
||||
<CardDescription>
|
||||
Programmato: {course.scheduledDate ? format(new Date(course.scheduledDate), "dd MMM yyyy", { locale: it }) : "N/D"}
|
||||
</CardDescription>
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Automezzi in Uso</CardTitle>
|
||||
<Car className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-blue-500" data-testid="text-inuse-vehicles">
|
||||
{inUseVehicles}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={course.courseType === "mandatory" ? "default" : "secondary"}>
|
||||
{course.courseType === "mandatory" ? "Obbligatorio" : "Facoltativo"}
|
||||
</Badge>
|
||||
<Badge variant={course.status === "completed" ? "default" : "secondary"}>
|
||||
{course.status}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(course.id)}
|
||||
data-testid={`button-delete-course-${course.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{courses.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nessun corso di formazione programmato
|
||||
<p className="text-xs text-muted-foreground">
|
||||
assegnati a turni
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AbsencesTab() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
|
||||
const { data: absences = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/absences"],
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("/api/absences", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/absences"] });
|
||||
toast({ title: "Assenza registrata" });
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest(`/api/absences/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/absences"] });
|
||||
toast({ title: "Assenza eliminata" });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Caricamento assenze...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-absence">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuova Assenza
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Registra Assenza</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
createMutation.mutate({
|
||||
guardId: formData.get("guardId"),
|
||||
type: formData.get("type"),
|
||||
startDate: formData.get("startDate"),
|
||||
endDate: formData.get("endDate"),
|
||||
notes: formData.get("notes"),
|
||||
isApproved: false,
|
||||
needsSubstitute: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="guardId">ID Guardia</Label>
|
||||
<Input id="guardId" name="guardId" required data-testid="input-absence-guard-id" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="type">Tipo Assenza</Label>
|
||||
<Select name="type" required>
|
||||
<SelectTrigger data-testid="select-absence-type">
|
||||
<SelectValue placeholder="Seleziona tipo" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="sick_leave">Malattia</SelectItem>
|
||||
<SelectItem value="vacation">Ferie</SelectItem>
|
||||
<SelectItem value="personal_leave">Permesso</SelectItem>
|
||||
<SelectItem value="injury">Infortunio</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="startDate">Data Inizio</Label>
|
||||
<Input id="startDate" name="startDate" type="date" required data-testid="input-start-date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="endDate">Data Fine</Label>
|
||||
<Input id="endDate" name="endDate" type="date" required data-testid="input-end-date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="notes">Note</Label>
|
||||
<Input id="notes" name="notes" data-testid="input-absence-notes" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-absence">
|
||||
{createMutation.isPending ? "Registrazione..." : "Registra Assenza"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{absences.map((absence: any) => (
|
||||
<Card key={absence.id} data-testid={`card-absence-${absence.id}`}>
|
||||
{/* Vehicles List */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">
|
||||
{absence.type === "sick_leave" && "Malattia"}
|
||||
{absence.type === "vacation" && "Ferie"}
|
||||
{absence.type === "personal_leave" && "Permesso"}
|
||||
{absence.type === "injury" && "Infortunio"}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Dal {format(new Date(absence.startDate), "dd MMM", { locale: it })} al{" "}
|
||||
{format(new Date(absence.endDate), "dd MMM yyyy", { locale: it })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={absence.isApproved ? "default" : "secondary"}>
|
||||
{absence.isApproved ? "Approvata" : "In attesa"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(absence.id)}
|
||||
data-testid={`button-delete-absence-${absence.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>Automezzi - {locationLabels[selectedLocation as keyof typeof locationLabels]}</CardTitle>
|
||||
<CardDescription>
|
||||
Stato e disponibilità automezzi per sede selezionata
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
))}
|
||||
{absences.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nessuna assenza registrata
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HolidaysTab() {
|
||||
const { toast } = useToast();
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
const { data: holidays = [], isLoading } = useQuery({
|
||||
queryKey: ["/api/holidays", currentYear],
|
||||
queryFn: () => fetch(`/api/holidays?year=${currentYear}`).then((r) => r.json()),
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data: any) => apiRequest("/api/holidays", "POST", data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/holidays", currentYear] });
|
||||
toast({ title: "Festività creata" });
|
||||
setIsCreateOpen(false);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => apiRequest(`/api/holidays/${id}`, "DELETE"),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/holidays", currentYear] });
|
||||
toast({ title: "Festività eliminata" });
|
||||
},
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="text-center py-8">Caricamento festività...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-end">
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button data-testid="button-create-holiday">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Nuova Festività
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Nuova Festività</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const dateValue = formData.get("date") as string;
|
||||
createMutation.mutate({
|
||||
name: formData.get("name"),
|
||||
date: dateValue,
|
||||
year: new Date(dateValue).getFullYear(),
|
||||
isNational: formData.get("isNational") === "true",
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="name">Nome Festività</Label>
|
||||
<Input id="name" name="name" required data-testid="input-holiday-name" placeholder="es. Natale, 1° Maggio..." />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="date">Data</Label>
|
||||
<Input id="date" name="date" type="date" required data-testid="input-holiday-date" />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="isNational">Tipo</Label>
|
||||
<Select name="isNational" defaultValue="true">
|
||||
<SelectTrigger data-testid="select-holiday-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">Nazionale</SelectItem>
|
||||
<SelectItem value="false">Regionale</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{filteredVehicles.map((vehicle) => (
|
||||
<div
|
||||
key={vehicle.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card hover-elevate"
|
||||
data-testid={`card-vehicle-${vehicle.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Car className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold" data-testid={`text-vehicle-${vehicle.id}`}>
|
||||
{vehicle.brand} {vehicle.model}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{vehicle.licensePlate}
|
||||
{vehicle.year && ` • Anno ${vehicle.year}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{locationLabels[vehicle.location as keyof typeof locationLabels]}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={vehicleStatusColors[vehicle.status]}
|
||||
data-testid={`badge-status-${vehicle.id}`}
|
||||
>
|
||||
{vehicleStatusLabels[vehicle.status]}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredVehicles.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 automezzo disponibile per questa sede</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={createMutation.isPending} data-testid="button-submit-holiday">
|
||||
{createMutation.isPending ? "Creazione..." : "Crea Festività"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{holidays.map((holiday: any) => (
|
||||
<Card key={holiday.id} data-testid={`card-holiday-${holiday.id}`}>
|
||||
{/* Active Shifts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-lg">{holiday.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{format(new Date(holiday.date), "EEEE dd MMMM yyyy", { locale: it })}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant={holiday.isNational ? "default" : "secondary"}>
|
||||
{holiday.isNational ? "Nazionale" : "Regionale"}
|
||||
</Badge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => deleteMutation.mutate(holiday.id)}
|
||||
data-testid={`button-delete-holiday-${holiday.id}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>Turni Attivi - {locationLabels[selectedLocation as keyof typeof locationLabels]}</CardTitle>
|
||||
<CardDescription>
|
||||
Turni in corso per sede selezionata
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{filteredShifts.filter(s => s.status === "active").map((shift) => {
|
||||
const site = sites.find(s => s.id === shift.siteId);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={shift.id}
|
||||
className="flex items-center justify-between p-4 rounded-lg border bg-card hover-elevate"
|
||||
data-testid={`card-shift-${shift.id}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-muted">
|
||||
<Calendar className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold" data-testid={`text-shift-site-${shift.id}`}>
|
||||
{site?.name || "Sito sconosciuto"}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{format(new Date(shift.startTime), "HH:mm", { locale: it })} -
|
||||
{format(new Date(shift.endTime), "HH:mm", { locale: it })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{site && locationLabels[site.location as keyof typeof locationLabels]}
|
||||
</Badge>
|
||||
<Badge variant="default">
|
||||
Attivo
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredShifts.filter(s => s.status === "active").length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
<Calendar className="h-12 w-12 mx-auto mb-2 opacity-50" />
|
||||
<p>Nessun turno attivo per questa sede</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{holidays.length === 0 && (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
Nessuna festività configurata per {currentYear}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
189
client/src/pages/services.tsx
Normal file
189
client/src/pages/services.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Site } from "@shared/schema";
|
||||
import { Building2, Shield, Eye, MapPin, Zap } from "lucide-react";
|
||||
|
||||
const serviceTypeInfo = {
|
||||
fixed_post: {
|
||||
label: "Presidio Fisso",
|
||||
description: "Guardia fissa presso una struttura",
|
||||
icon: Building2,
|
||||
color: "bg-blue-500/10 text-blue-500 border-blue-500/20"
|
||||
},
|
||||
patrol: {
|
||||
label: "Pattugliamento",
|
||||
description: "Ronde e controlli su area",
|
||||
icon: Eye,
|
||||
color: "bg-green-500/10 text-green-500 border-green-500/20"
|
||||
},
|
||||
night_inspection: {
|
||||
label: "Ispettorato Notturno",
|
||||
description: "Controlli notturni programmati",
|
||||
icon: Shield,
|
||||
color: "bg-purple-500/10 text-purple-500 border-purple-500/20"
|
||||
},
|
||||
quick_response: {
|
||||
label: "Pronto Intervento",
|
||||
description: "Intervento rapido su chiamata",
|
||||
icon: Zap,
|
||||
color: "bg-orange-500/10 text-orange-500 border-orange-500/20"
|
||||
}
|
||||
} as const;
|
||||
|
||||
export default function Services() {
|
||||
const { data: sites = [], isLoading } = useQuery<Site[]>({
|
||||
queryKey: ["/api/sites"],
|
||||
});
|
||||
|
||||
// Calculate statistics per service type
|
||||
const stats = Object.keys(serviceTypeInfo).reduce((acc, type) => {
|
||||
const sitesForType = sites.filter(s => s.shiftType === type);
|
||||
acc[type] = {
|
||||
total: sitesForType.length,
|
||||
active: sitesForType.filter(s => s.isActive).length,
|
||||
requiresArmed: sitesForType.filter(s => s.requiresArmed).length,
|
||||
requiresDriver: sitesForType.filter(s => s.requiresDriverLicense).length,
|
||||
};
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-semibold mb-2">Gestione Servizi</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Panoramica tipologie di servizio e relative configurazioni
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8 text-muted-foreground">Caricamento servizi...</div>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{Object.entries(serviceTypeInfo).map(([type, info]) => {
|
||||
const Icon = info.icon;
|
||||
const stat = stats[type] || { total: 0, active: 0, requiresArmed: 0, requiresDriver: 0 };
|
||||
|
||||
return (
|
||||
<Card key={type} data-testid={`card-service-${type}`}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`p-2 rounded-lg ${info.color}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-xl">{info.label}</CardTitle>
|
||||
<CardDescription className="mt-1">{info.description}</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Siti Totali</p>
|
||||
<p className="text-2xl font-semibold" data-testid={`text-total-${type}`}>
|
||||
{stat.total}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Attivi</p>
|
||||
<p className="text-2xl font-semibold text-green-500" data-testid={`text-active-${type}`}>
|
||||
{stat.active}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Richiedono Armati</p>
|
||||
<p className="text-lg font-semibold" data-testid={`text-armed-${type}`}>
|
||||
{stat.requiresArmed}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Richiedono Patente</p>
|
||||
<p className="text-lg font-semibold" data-testid={`text-driver-${type}`}>
|
||||
{stat.requiresDriver}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
{sites.filter(s => s.shiftType === type && s.location === 'roccapiemonte').length} Roccapiemonte
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
{sites.filter(s => s.shiftType === type && s.location === 'milano').length} Milano
|
||||
</Badge>
|
||||
<Badge variant="outline" className="font-normal">
|
||||
<MapPin className="h-3 w-3 mr-1" />
|
||||
{sites.filter(s => s.shiftType === type && s.location === 'roma').length} Roma
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informazioni Tipologie Servizio</CardTitle>
|
||||
<CardDescription>Caratteristiche e utilizzo delle diverse tipologie</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Building2 className="h-5 w-5 text-blue-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Presidio Fisso</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Utilizzato per siti che richiedono sorveglianza continua con presenza fissa delle guardie.
|
||||
Ideale per banche, musei, uffici pubblici.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Eye className="h-5 w-5 text-green-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Pattugliamento</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Servizio di ronde mobili su area estesa. Le guardie effettuano controlli periodici
|
||||
seguendo percorsi predefiniti. Richiede spesso patente di guida.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Shield className="h-5 w-5 text-purple-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Ispettorato Notturno</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Controlli specifici durante le ore notturne. Prevede verifiche programmate
|
||||
di sicurezza e aperture/chiusure di strutture.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 p-3 rounded-lg bg-muted/50">
|
||||
<Zap className="h-5 w-5 text-orange-500 mt-0.5" />
|
||||
<div>
|
||||
<h4 className="font-semibold">Pronto Intervento</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Servizio di intervento rapido su chiamata. Le guardie devono essere disponibili
|
||||
per interventi urgenti, spesso armati e con veicolo dedicato.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -109,6 +109,7 @@ export default function Vehicles() {
|
||||
brand: "",
|
||||
model: "",
|
||||
vehicleType: "car",
|
||||
location: "roccapiemonte",
|
||||
year: undefined,
|
||||
assignedGuardId: null,
|
||||
status: "available",
|
||||
@ -213,6 +214,7 @@ export default function Vehicles() {
|
||||
brand: vehicle.brand,
|
||||
model: vehicle.model,
|
||||
vehicleType: vehicle.vehicleType,
|
||||
location: vehicle.location,
|
||||
year: vehicle.year ?? undefined,
|
||||
assignedGuardId: vehicle.assignedGuardId,
|
||||
status: vehicle.status,
|
||||
@ -370,7 +372,7 @@ export default function Vehicles() {
|
||||
</DialogHeader>
|
||||
<Form {...createForm}>
|
||||
<form onSubmit={createForm.handleSubmit((data) => createVehicleMutation.mutate(data))} className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="licensePlate"
|
||||
@ -384,6 +386,28 @@ export default function Vehicles() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sede *</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value} data-testid="select-create-location">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Seleziona sede" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||
<SelectItem value="milano">Milano</SelectItem>
|
||||
<SelectItem value="roma">Roma</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={createForm.control}
|
||||
name="vehicleType"
|
||||
@ -575,7 +599,7 @@ export default function Vehicles() {
|
||||
</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">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="licensePlate"
|
||||
@ -589,6 +613,28 @@ export default function Vehicles() {
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="location"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Sede *</FormLabel>
|
||||
<Select onValueChange={field.onChange} value={field.value} data-testid="select-edit-location">
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Seleziona sede" />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
||||
<SelectItem value="milano">Milano</SelectItem>
|
||||
<SelectItem value="roma">Roma</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={editForm.control}
|
||||
name="vehicleType"
|
||||
|
||||
@ -8,6 +8,7 @@ VigilanzaTurni is a professional 24/7 shift management system designed for secur
|
||||
- Dark mode di default
|
||||
- Design operativo e funzionale (non decorativo)
|
||||
- Focus su efficienza e densità informativa
|
||||
- **Testing**: Tutti i test vengono eseguiti ESCLUSIVAMENTE sul server esterno (vt.alfacom.it) con autenticazione locale (non Replit Auth)
|
||||
|
||||
## System Architecture
|
||||
|
||||
|
||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"lastUpdate": "2025-10-17T07:44:09.569Z",
|
||||
"version": "1.0.7",
|
||||
"lastUpdate": "2025-10-17T08:07:39.479Z",
|
||||
"changelog": [
|
||||
{
|
||||
"version": "1.0.7",
|
||||
"date": "2025-10-17",
|
||||
"type": "patch",
|
||||
"description": "Deployment automatico v1.0.7"
|
||||
},
|
||||
{
|
||||
"version": "1.0.6",
|
||||
"date": "2025-10-17",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user