Compare commits

..

No commits in common. "47fa3104e3022b94c04de438d21bc3bebe5b3e3c" and "2bf8b54dc605946985eaf888c932c95a53c3d2ee" have entirely different histories.

8 changed files with 40 additions and 685 deletions

View File

@ -1,11 +1,9 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { KPICard } from "@/components/kpi-card"; import { KPICard } from "@/components/kpi-card";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { Users, Calendar, MapPin, AlertTriangle, Clock, CheckCircle, Building2 } from "lucide-react"; import { Users, Calendar, MapPin, AlertTriangle, Clock, CheckCircle } from "lucide-react";
import { ShiftWithDetails, GuardWithCertifications, Site } from "@shared/schema"; import { ShiftWithDetails, GuardWithCertifications, Site } from "@shared/schema";
import { formatDistanceToNow, format } from "date-fns"; import { formatDistanceToNow, format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
@ -13,7 +11,6 @@ import { Skeleton } from "@/components/ui/skeleton";
export default function Dashboard() { export default function Dashboard() {
const { user } = useAuth(); const { user } = useAuth();
const [selectedLocation, setSelectedLocation] = useState<string>("all");
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({ const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts/active"], queryKey: ["/api/shifts/active"],
@ -27,64 +24,25 @@ export default function Dashboard() {
queryKey: ["/api/sites"], queryKey: ["/api/sites"],
}); });
// Filter data by location
const filteredGuards = selectedLocation === "all"
? guards
: guards?.filter(g => g.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;
});
// Calculate KPIs // Calculate KPIs
const activeShifts = filteredShifts?.filter(s => s.status === "active").length || 0; const activeShifts = shifts?.filter(s => s.status === "active").length || 0;
const totalGuards = filteredGuards?.length || 0; const totalGuards = guards?.length || 0;
const activeSites = filteredSites?.filter(s => s.isActive).length || 0; const activeSites = sites?.filter(s => s.isActive).length || 0;
// Expiring certifications (next 30 days) // Expiring certifications (next 30 days)
const expiringCerts = filteredGuards?.flatMap(g => const expiringCerts = guards?.flatMap(g =>
g.certifications.filter(c => c.status === "expiring_soon") g.certifications.filter(c => c.status === "expiring_soon")
).length || 0; ).length || 0;
const isLoading = shiftsLoading || guardsLoading || sitesLoading; const isLoading = shiftsLoading || guardsLoading || sitesLoading;
const locationLabels: Record<string, string> = {
all: "Tutte le Sedi",
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma"
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-start justify-between"> <div>
<div> <h1 className="text-3xl font-semibold mb-2">Dashboard Operativa</h1>
<h1 className="text-3xl font-semibold mb-2">Dashboard Operativa</h1> <p className="text-muted-foreground">
<p className="text-muted-foreground"> Benvenuto, {user?.firstName} {user?.lastName}
Benvenuto, {user?.firstName} {user?.lastName} </p>
</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 placeholder="Seleziona sede" />
</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> </div>
{/* KPI Cards */} {/* KPI Cards */}
@ -145,9 +103,9 @@ export default function Dashboard() {
<Skeleton className="h-16" /> <Skeleton className="h-16" />
<Skeleton className="h-16" /> <Skeleton className="h-16" />
</div> </div>
) : filteredShifts && filteredShifts.length > 0 ? ( ) : shifts && shifts.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
{filteredShifts.slice(0, 5).map((shift) => ( {shifts.slice(0, 5).map((shift) => (
<div <div
key={shift.id} key={shift.id}
className="flex items-center justify-between p-3 rounded-md border hover-elevate" className="flex items-center justify-between p-3 rounded-md border hover-elevate"
@ -190,9 +148,9 @@ export default function Dashboard() {
<Skeleton className="h-16" /> <Skeleton className="h-16" />
<Skeleton className="h-16" /> <Skeleton className="h-16" />
</div> </div>
) : filteredGuards ? ( ) : guards ? (
<div className="space-y-3"> <div className="space-y-3">
{filteredGuards {guards
.flatMap(guard => .flatMap(guard =>
guard.certifications guard.certifications
.filter(c => c.status === "expiring_soon" || c.status === "expired") .filter(c => c.status === "expiring_soon" || c.status === "expired")
@ -218,7 +176,7 @@ export default function Dashboard() {
</StatusBadge> </StatusBadge>
</div> </div>
))} ))}
{filteredGuards.flatMap(g => g.certifications.filter(c => c.status !== "valid")).length === 0 && ( {guards.flatMap(g => g.certifications.filter(c => c.status !== "valid")).length === 0 && (
<div className="flex items-center gap-2 text-sm text-muted-foreground justify-center py-8"> <div className="flex items-center gap-2 text-sm text-muted-foreground justify-center py-8">
<CheckCircle className="h-4 w-4 text-[hsl(140,60%,45%)]" /> <CheckCircle className="h-4 w-4 text-[hsl(140,60%,45%)]" />
Tutte le certificazioni sono valide Tutte le certificazioni sono valide

View File

@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { Loader2, Save, Settings } from "lucide-react"; import { Loader2, Save, Settings } from "lucide-react";
import type { ContractParameters } from "@shared/schema"; import type { ContractParameters } from "@shared/schema";
@ -346,61 +345,6 @@ export default function Parameters() {
</CardContent> </CardContent>
</Card> </Card>
{/* Buoni Pasto */}
<Card>
<CardHeader>
<CardTitle>Buoni Pasto</CardTitle>
<CardDescription>Configurazione ticket restaurant per turni superiori a soglia ore</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="mealVoucherEnabled">Buoni Pasto Attivi</Label>
<Switch
id="mealVoucherEnabled"
checked={formData.mealVoucherEnabled ?? true}
onCheckedChange={(checked) => setFormData({ ...formData, mealVoucherEnabled: checked })}
disabled={!isEditing}
data-testid="switch-meal-voucher-enabled"
/>
</div>
<p className="text-sm text-muted-foreground">
Abilita emissione buoni pasto automatici
</p>
</div>
<div className="space-y-2">
<Label htmlFor="mealVoucherAfterHours">Ore Minime per Buono Pasto</Label>
<Input
id="mealVoucherAfterHours"
type="number"
value={formData.mealVoucherAfterHours ?? 6}
onChange={(e) => setFormData({ ...formData, mealVoucherAfterHours: parseInt(e.target.value) })}
disabled={!isEditing || !formData.mealVoucherEnabled}
data-testid="input-meal-voucher-after-hours"
/>
<p className="text-sm text-muted-foreground">
Ore di turno necessarie per diritto al buono
</p>
</div>
<div className="space-y-2">
<Label htmlFor="mealVoucherAmount">Importo Buono Pasto ()</Label>
<Input
id="mealVoucherAmount"
type="number"
value={formData.mealVoucherAmount ?? 8}
onChange={(e) => setFormData({ ...formData, mealVoucherAmount: parseInt(e.target.value) })}
disabled={!isEditing || !formData.mealVoucherEnabled}
data-testid="input-meal-voucher-amount"
/>
<p className="text-sm text-muted-foreground">
Valore nominale ticket (facoltativo)
</p>
</div>
</CardContent>
</Card>
{/* Tipo Contratto */} {/* Tipo Contratto */}
<Card> <Card>
<CardHeader> <CardHeader>

View File

@ -4,14 +4,12 @@ import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@s
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { insertShiftFormSchema } from "@shared/schema"; import { insertShiftFormSchema } from "@shared/schema";
import { Plus, Calendar, MapPin, Users, Clock, UserPlus, X, Shield, Car, Heart, Flame, Pencil, Building2 } from "lucide-react"; import { Plus, Calendar, MapPin, Users, Clock, UserPlus, X, Shield, Car, Heart, Flame, Pencil } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
@ -26,9 +24,6 @@ export default function Shifts() {
const [selectedShift, setSelectedShift] = useState<ShiftWithDetails | null>(null); const [selectedShift, setSelectedShift] = useState<ShiftWithDetails | null>(null);
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false); const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const [editingShift, setEditingShift] = useState<ShiftWithDetails | null>(null); const [editingShift, setEditingShift] = useState<ShiftWithDetails | null>(null);
const [selectedLocation, setSelectedLocation] = useState<string>("all");
const [ccnlViolations, setCcnlViolations] = useState<Array<{type: string, message: string}>>([]);
const [pendingAssignment, setPendingAssignment] = useState<{shiftId: string, guardId: string} | null>(null);
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({ const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts"], queryKey: ["/api/shifts"],
@ -42,22 +37,6 @@ export default function Shifts() {
queryKey: ["/api/guards"], queryKey: ["/api/guards"],
}); });
// Filter data by location
const filteredShifts = selectedLocation === "all"
? shifts
: shifts?.filter(s => {
const site = sites?.find(site => site.id === s.siteId);
return site?.location === selectedLocation;
});
const filteredSites = selectedLocation === "all"
? sites
: sites?.filter(s => s.location === selectedLocation);
const filteredGuards = selectedLocation === "all"
? guards
: guards?.filter(g => g.location === selectedLocation);
const form = useForm({ const form = useForm({
resolver: zodResolver(insertShiftFormSchema), resolver: zodResolver(insertShiftFormSchema),
defaultValues: { defaultValues: {
@ -104,47 +83,6 @@ export default function Shifts() {
createMutation.mutate(data); createMutation.mutate(data);
}; };
// Validate CCNL before assignment
const validateCcnl = async (shiftId: string, guardId: string) => {
const shift = shifts?.find(s => s.id === shiftId);
if (!shift) return { violations: [] };
try {
const response = await apiRequest("POST", "/api/shifts/validate-ccnl", {
guardId,
shiftStartTime: shift.startTime,
shiftEndTime: shift.endTime
});
return response as { violations: Array<{type: string, message: string}> };
} catch (error) {
console.error("Errore validazione CCNL:", error);
return { violations: [] };
}
};
const handleAssignGuard = async (guardId: string) => {
if (!selectedShift) return;
// Validate CCNL
const { violations } = await validateCcnl(selectedShift.id, guardId);
if (violations.length > 0) {
setCcnlViolations(violations);
setPendingAssignment({ shiftId: selectedShift.id, guardId });
} else {
// No violations, assign directly
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const confirmAssignment = () => {
if (pendingAssignment) {
assignGuardMutation.mutate(pendingAssignment);
setPendingAssignment(null);
setCcnlViolations([]);
}
};
const assignGuardMutation = useMutation({ const assignGuardMutation = useMutation({
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => { mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId }); return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId });
@ -225,6 +163,12 @@ export default function Shifts() {
}, },
}); });
const handleAssignGuard = (guardId: string) => {
if (selectedShift) {
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const handleRemoveAssignment = (assignmentId: string) => { const handleRemoveAssignment = (assignmentId: string) => {
removeAssignmentMutation.mutate(assignmentId); removeAssignmentMutation.mutate(assignmentId);
}; };
@ -294,28 +238,13 @@ export default function Shifts() {
Calendario 24/7 con assegnazione guardie Calendario 24/7 con assegnazione guardie
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<div className="flex items-center gap-2"> <DialogTrigger asChild>
<Building2 className="h-5 w-5 text-muted-foreground" /> <Button data-testid="button-add-shift">
<Select value={selectedLocation} onValueChange={setSelectedLocation}> <Plus className="h-4 w-4 mr-2" />
<SelectTrigger className="w-[180px]" data-testid="select-location-shifts"> Nuovo Turno
<SelectValue placeholder="Seleziona sede" /> </Button>
</SelectTrigger> </DialogTrigger>
<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>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button data-testid="button-add-shift">
<Plus className="h-4 w-4 mr-2" />
Nuovo Turno
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>Nuovo Turno</DialogTitle> <DialogTitle>Nuovo Turno</DialogTitle>
@ -338,7 +267,7 @@ export default function Shifts() {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{filteredSites?.map((site) => ( {sites?.map((site) => (
<SelectItem key={site.id} value={site.id}> <SelectItem key={site.id} value={site.id}>
{site.name} {site.name}
</SelectItem> </SelectItem>
@ -415,7 +344,6 @@ export default function Shifts() {
</Form> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div>
</div> </div>
{shiftsLoading ? ( {shiftsLoading ? (
@ -424,9 +352,9 @@ export default function Shifts() {
<Skeleton className="h-32" /> <Skeleton className="h-32" />
<Skeleton className="h-32" /> <Skeleton className="h-32" />
</div> </div>
) : filteredShifts && filteredShifts.length > 0 ? ( ) : shifts && shifts.length > 0 ? (
<div className="space-y-4"> <div className="space-y-4">
{filteredShifts.map((shift) => ( {shifts.map((shift) => (
<Card key={shift.id} className="hover-elevate" data-testid={`card-shift-${shift.id}`}> <Card key={shift.id} className="hover-elevate" data-testid={`card-shift-${shift.id}`}>
<CardHeader> <CardHeader>
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
@ -565,7 +493,7 @@ export default function Shifts() {
<h3 className="font-medium">Guardie Disponibili</h3> <h3 className="font-medium">Guardie Disponibili</h3>
{guards && guards.length > 0 ? ( {guards && guards.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{filteredGuards?.map((guard) => { {guards.map((guard) => {
const assigned = isGuardAssigned(guard.id); const assigned = isGuardAssigned(guard.id);
const canAssign = canGuardBeAssigned(guard); const canAssign = canGuardBeAssigned(guard);
@ -657,7 +585,7 @@ export default function Shifts() {
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{filteredSites?.map((site) => ( {sites?.map((site) => (
<SelectItem key={site.id} value={site.id}> <SelectItem key={site.id} value={site.id}>
{site.name} {site.name}
</SelectItem> </SelectItem>
@ -758,43 +686,6 @@ export default function Shifts() {
</Form> </Form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* CCNL Violations Alert Dialog */}
<AlertDialog open={pendingAssignment !== null} onOpenChange={(open) => !open && setPendingAssignment(null)}>
<AlertDialogContent data-testid="dialog-ccnl-violations">
<AlertDialogHeader>
<AlertDialogTitle> Violazioni CCNL Rilevate</AlertDialogTitle>
<AlertDialogDescription>
L'assegnazione di questa guardia al turno viola i seguenti limiti contrattuali:
</AlertDialogDescription>
</AlertDialogHeader>
<div className="space-y-2">
{ccnlViolations.map((violation, index) => (
<Alert key={index} variant="destructive" data-testid={`alert-violation-${index}`}>
<AlertDescription>{violation.message}</AlertDescription>
</Alert>
))}
</div>
<AlertDialogFooter>
<AlertDialogCancel
data-testid="button-cancel-assignment"
onClick={() => {
setPendingAssignment(null);
setCcnlViolations([]);
}}
>
Annulla
</AlertDialogCancel>
<AlertDialogAction
data-testid="button-confirm-assignment"
onClick={confirmAssignment}
className="bg-destructive hover:bg-destructive/90"
>
Procedi Comunque
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
} }

View File

@ -1,7 +1,7 @@
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza # VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
## Overview ## Overview
VigilanzaTurni is a professional 24/7 shift management system designed for security companies. It offers multi-role authentication (Admin, Coordinator, Guard, Client), comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The system supports multi-location operations (Roccapiemonte, Milano, Roma) managing 250+ security personnel across different branches. The project aims to streamline operations and enhance efficiency for security institutes. VigilanzaTurni is a professional 24/7 shift management system designed for security companies. It offers multi-role authentication (Admin, Coordinator, Guard, Client), comprehensive guard and site management, 24/7 shift planning, a live operational dashboard with KPIs, reporting for worked hours, and a notification system. The project aims to streamline operations and enhance efficiency for security institutes.
## User Preferences ## User Preferences
- Interfaccia in italiano - Interfaccia in italiano

View File

@ -4,9 +4,9 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth"; import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth"; import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db"; import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema"; import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema } from "@shared/schema";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO } from "date-fns"; import { differenceInDays } from "date-fns";
// Determina quale sistema auth usare basandosi sull'ambiente // Determina quale sistema auth usare basandosi sull'ambiente
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS; const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
@ -638,169 +638,6 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
// ============= CCNL VALIDATION =============
app.post("/api/shifts/validate-ccnl", isAuthenticated, async (req, res) => {
try {
const { guardId, shiftStartTime, shiftEndTime } = req.body;
if (!guardId || !shiftStartTime || !shiftEndTime) {
return res.status(400).json({ message: "Missing required fields" });
}
const startTime = new Date(shiftStartTime);
const endTime = new Date(shiftEndTime);
// Load CCNL parameters
const params = await db.select().from(contractParameters).limit(1);
if (params.length === 0) {
return res.status(500).json({ message: "CCNL parameters not found" });
}
const ccnl = params[0];
const violations: Array<{type: string, message: string}> = [];
// Calculate shift hours precisely (in minutes, then convert)
const shiftMinutes = differenceInMinutes(endTime, startTime);
const shiftHours = Math.round((shiftMinutes / 60) * 100) / 100; // Round to 2 decimals
// Check max daily hours
if (shiftHours > ccnl.maxHoursPerDay) {
violations.push({
type: "MAX_DAILY_HOURS",
message: `Turno di ${shiftHours}h supera il limite giornaliero di ${ccnl.maxHoursPerDay}h`
});
}
// Get guard's shifts for the week (overlapping with week window)
const weekStart = startOfWeek(startTime, { weekStartsOn: 1 }); // Monday
const weekEnd = endOfWeek(startTime, { weekStartsOn: 1 });
const weekShifts = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
// Shift overlaps week if: (shift_start <= week_end) AND (shift_end >= week_start)
lte(shifts.startTime, weekEnd),
gte(shifts.endTime, weekStart)
)
);
// Calculate weekly hours precisely (only overlap with week window)
let weeklyMinutes = 0;
// Add overlap of new shift with week (clamp to 0 if outside window)
const newShiftStart = startTime < weekStart ? weekStart : startTime;
const newShiftEnd = endTime > weekEnd ? weekEnd : endTime;
weeklyMinutes += Math.max(0, differenceInMinutes(newShiftEnd, newShiftStart));
// Add overlap of existing shifts with week (clamp to 0 if outside window)
for (const { shifts: shift } of weekShifts) {
const shiftStart = shift.startTime < weekStart ? weekStart : shift.startTime;
const shiftEnd = shift.endTime > weekEnd ? weekEnd : shift.endTime;
weeklyMinutes += Math.max(0, differenceInMinutes(shiftEnd, shiftStart));
}
const weeklyHours = Math.round((weeklyMinutes / 60) * 100) / 100;
if (weeklyHours > ccnl.maxHoursPerWeek) {
violations.push({
type: "MAX_WEEKLY_HOURS",
message: `Ore settimanali (${weeklyHours}h) superano il limite di ${ccnl.maxHoursPerWeek}h`
});
}
// Check consecutive days (chronological order ASC, only past shifts before this one)
const pastShifts = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
lte(shifts.startTime, startTime)
)
)
.orderBy(asc(shifts.startTime));
// Build consecutive days map
const shiftDays = new Set<string>();
for (const { shifts: shift } of pastShifts) {
shiftDays.add(startOfDay(shift.startTime).toISOString());
}
shiftDays.add(startOfDay(startTime).toISOString());
// Count consecutive days backwards from new shift
let consecutiveDays = 0;
let checkDate = startOfDay(startTime);
while (shiftDays.has(checkDate.toISOString())) {
consecutiveDays++;
checkDate = new Date(checkDate);
checkDate.setDate(checkDate.getDate() - 1);
}
if (consecutiveDays > 6) {
violations.push({
type: "MAX_CONSECUTIVE_DAYS",
message: `${consecutiveDays} giorni consecutivi superano il limite di 6 giorni`
});
}
// Check for overlapping shifts (guard already assigned to another shift at same time)
// Overlap if: (existing_start < new_end) AND (existing_end > new_start)
// Note: Use strict inequalities to allow back-to-back shifts (end == start is OK)
const allAssignments = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(eq(shiftAssignments.guardId, guardId));
const overlappingShifts = allAssignments.filter(({ shifts: shift }: any) => {
return shift.startTime < endTime && shift.endTime > startTime;
});
if (overlappingShifts.length > 0) {
violations.push({
type: "OVERLAPPING_SHIFT",
message: `Guardia già assegnata a un turno sovrapposto in questo orario`
});
}
// Check rest hours (only last shift that ended before this one)
const lastCompletedShifts = await db
.select()
.from(shiftAssignments)
.innerJoin(shifts, eq(shifts.id, shiftAssignments.shiftId))
.where(
and(
eq(shiftAssignments.guardId, guardId),
lte(shifts.endTime, startTime)
)
)
.orderBy(desc(shifts.endTime))
.limit(1);
if (lastCompletedShifts.length > 0) {
const lastShift = lastCompletedShifts[0].shifts;
const restMinutes = differenceInMinutes(startTime, lastShift.endTime);
const restHours = Math.round((restMinutes / 60) * 100) / 100;
if (restHours < ccnl.minDailyRestHours) {
violations.push({
type: "MIN_REST_HOURS",
message: `Riposo di ${restHours}h inferiore al minimo di ${ccnl.minDailyRestHours}h`
});
}
}
res.json({ violations, ccnlParams: ccnl });
} catch (error) {
console.error("Error validating CCNL:", error);
res.status(500).json({ message: "Failed to validate CCNL" });
}
});
// ============= SHIFT ASSIGNMENT ROUTES ============= // ============= SHIFT ASSIGNMENT ROUTES =============
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => { app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
try { try {

View File

@ -1,255 +0,0 @@
import { db } from "./db";
import { users, guards, sites, vehicles, contractParameters } from "@shared/schema";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";
async function seed() {
console.log("🌱 Avvio seed database multi-sede...");
// Create CCNL contract parameters
console.log("📋 Creazione parametri contrattuali CCNL...");
const existingParams = await db.select().from(contractParameters).limit(1);
if (existingParams.length === 0) {
await db.insert(contractParameters).values({
contractType: "CCNL Vigilanza Privata",
maxHoursPerDay: 9,
maxOvertimePerDay: 2,
maxHoursPerWeek: 48,
maxOvertimePerWeek: 8,
minDailyRestHours: 11,
minDailyRestHoursReduced: 9,
maxDailyRestReductionsPerMonth: 3,
maxDailyRestReductionsPerYear: 20,
minWeeklyRestHours: 24,
maxNightHoursPerWeek: 40,
pauseMinutesIfOver6Hours: 30,
holidayPayIncrease: 30,
nightPayIncrease: 15,
overtimePayIncrease: 20,
mealVoucherEnabled: true,
mealVoucherAfterHours: 6,
mealVoucherAmount: 8
});
console.log(" ✓ Parametri CCNL creati");
} else {
console.log(" ✓ Parametri CCNL già esistenti");
}
// Locations
const locations = ["roccapiemonte", "milano", "roma"] as const;
const locationNames = {
roccapiemonte: "Roccapiemonte",
milano: "Milano",
roma: "Roma"
};
// Cleanup existing data (optional - comment out to preserve existing data)
// await db.delete(guards);
// await db.delete(sites);
// await db.delete(vehicles);
console.log("👥 Creazione guardie per ogni sede...");
// Create 10 guards per location
const guardNames = [
"Marco Rossi", "Luca Bianchi", "Giuseppe Verdi", "Francesco Romano",
"Alessandro Russo", "Andrea Marino", "Matteo Ferrari", "Lorenzo Conti",
"Davide Ricci", "Simone Moretti"
];
for (const location of locations) {
for (let i = 0; i < 10; i++) {
const fullName = guardNames[i];
const [firstName, ...lastNameParts] = fullName.split(" ");
const lastName = lastNameParts.join(" ");
const email = `${fullName.toLowerCase().replace(" ", ".")}@${location}.vt.alfacom.it`;
const badgeNumber = `${location.substring(0, 3).toUpperCase()}${String(i + 1).padStart(3, "0")}`;
// Check if user exists
const existingUser = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
let userId: string;
if (existingUser.length > 0) {
userId = existingUser[0].id;
console.log(` ✓ Utente esistente: ${email}`);
} else {
// Create user
const hashedPassword = await bcrypt.hash("guard123", 10);
const [newUser] = await db
.insert(users)
.values({
email,
firstName,
lastName,
passwordHash: hashedPassword,
role: "guard"
})
.returning();
userId = newUser.id;
console.log(` + Creato utente: ${email}`);
}
// Check if guard exists
const existingGuard = await db
.select()
.from(guards)
.where(eq(guards.badgeNumber, badgeNumber))
.limit(1);
if (existingGuard.length === 0) {
await db.insert(guards).values({
userId,
badgeNumber,
phoneNumber: `+39 ${330 + i} ${Math.floor(Math.random() * 1000000)}`,
location,
isArmed: i % 3 === 0, // 1 su 3 è armato
hasFireSafety: i % 2 === 0, // 1 su 2 ha antincendio
hasFirstAid: i % 4 === 0, // 1 su 4 ha primo soccorso
hasDriverLicense: i % 2 === 1, // 1 su 2 ha patente
languages: i === 0 ? ["italiano", "inglese"] : ["italiano"]
});
console.log(` + Creata guardia: ${badgeNumber} - ${name} (${locationNames[location]})`);
} else {
console.log(` ✓ Guardia esistente: ${badgeNumber}`);
}
}
}
console.log("\n🏢 Creazione clienti per ogni sede...");
// Create 10 clients per location
const companyNames = [
"Banca Centrale", "Ospedale San Marco", "Centro Commerciale Europa",
"Uffici Postali", "Museo Arte Moderna", "Palazzo Comunale",
"Stazione Ferroviaria", "Aeroporto Internazionale", "Università Statale",
"Tribunale Civile"
];
for (const location of locations) {
for (let i = 0; i < 10; i++) {
const companyName = companyNames[i];
const email = `${companyName.toLowerCase().replace(/ /g, ".")}@${location}.clienti.vt.it`;
// Check if client user exists
const existingClient = await db
.select()
.from(users)
.where(eq(users.email, email))
.limit(1);
let clientId: string;
if (existingClient.length > 0) {
clientId = existingClient[0].id;
console.log(` ✓ Cliente esistente: ${email}`);
} else {
const hashedPassword = await bcrypt.hash("client123", 10);
const [newClient] = await db
.insert(users)
.values({
email,
firstName: companyName,
lastName: locationNames[location],
passwordHash: hashedPassword,
role: "client"
})
.returning();
clientId = newClient.id;
console.log(` + Creato cliente: ${email}`);
}
// Check if site exists
const siteName = `${companyName} - ${locationNames[location]}`;
const existingSite = await db
.select()
.from(sites)
.where(eq(sites.name, siteName))
.limit(1);
if (existingSite.length === 0) {
const shiftTypes = ["fixed_post", "patrol", "night_inspection", "quick_response"] as const;
await db.insert(sites).values({
name: siteName,
address: `Via ${companyName} ${i + 1}, ${locationNames[location]}`,
clientId,
location,
shiftType: shiftTypes[i % 4],
minGuards: Math.floor(Math.random() * 3) + 1,
requiresArmed: i % 3 === 0,
requiresDriverLicense: i % 4 === 0,
isActive: true
});
console.log(` + Creato sito: ${siteName}`);
} else {
console.log(` ✓ Sito esistente: ${siteName}`);
}
}
}
console.log("\n🚗 Creazione automezzi per ogni sede...");
// Create vehicles per location
const vehicleBrands = [
{ brand: "Fiat", model: "Punto", type: "car" },
{ brand: "Volkswagen", model: "Polo", type: "car" },
{ brand: "Ford", model: "Transit", type: "van" },
{ brand: "Mercedes", model: "Sprinter", type: "van" },
{ brand: "BMW", model: "GS 750", type: "motorcycle" },
] as const;
for (const location of locations) {
for (let i = 0; i < 5; i++) {
const vehicle = vehicleBrands[i];
const licensePlate = `${location.substring(0, 2).toUpperCase()}${String(Math.floor(Math.random() * 1000)).padStart(3, "0")}${String.fromCharCode(65 + Math.floor(Math.random() * 26))}${String.fromCharCode(65 + Math.floor(Math.random() * 26))}`;
// Check if vehicle exists
const existingVehicle = await db
.select()
.from(vehicles)
.where(eq(vehicles.licensePlate, licensePlate))
.limit(1);
if (existingVehicle.length === 0) {
await db.insert(vehicles).values({
licensePlate,
brand: vehicle.brand,
model: vehicle.model,
vehicleType: vehicle.type,
year: 2018 + Math.floor(Math.random() * 6),
location,
status: i === 0 ? "in_use" : "available",
mileage: Math.floor(Math.random() * 100000) + 10000
});
console.log(` + Creato automezzo: ${licensePlate} - ${vehicle.brand} ${vehicle.model} (${locationNames[location]})`);
} else {
console.log(` ✓ Automezzo esistente: ${licensePlate}`);
}
}
}
console.log("\n✅ Seed completato!");
console.log(`
📊 Riepilogo:
- 30 guardie totali (10 per sede)
- 30 siti/clienti totali (10 per sede)
- 15 automezzi totali (5 per sede)
🔐 Credenziali:
- Guardie: *.guardia@[sede].vt.alfacom.it / guard123
- Clienti: *@[sede].clienti.vt.it / client123
- Admin: admin@vt.alfacom.it / admin123
`);
process.exit(0);
}
seed().catch((error) => {
console.error("❌ Errore seed:", error);
process.exit(1);
});

View File

@ -85,12 +85,6 @@ export const vehicleTypeEnum = pgEnum("vehicle_type", [
"suv", // SUV "suv", // SUV
]); ]);
export const locationEnum = pgEnum("location", [
"roccapiemonte", // Sede Roccapiemonte (Salerno)
"milano", // Sede Milano
"roma", // Sede Roma
]);
// ============= SESSION & AUTH TABLES (Replit Auth) ============= // ============= SESSION & AUTH TABLES (Replit Auth) =============
// Session storage table - mandatory for Replit Auth // Session storage table - mandatory for Replit Auth
@ -124,7 +118,6 @@ export const guards = pgTable("guards", {
userId: varchar("user_id").references(() => users.id), userId: varchar("user_id").references(() => users.id),
badgeNumber: varchar("badge_number").notNull().unique(), badgeNumber: varchar("badge_number").notNull().unique(),
phoneNumber: varchar("phone_number"), phoneNumber: varchar("phone_number"),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza
// Skills // Skills
isArmed: boolean("is_armed").default(false), isArmed: boolean("is_armed").default(false),
@ -158,7 +151,6 @@ export const vehicles = pgTable("vehicles", {
model: varchar("model").notNull(), // Modello model: varchar("model").notNull(), // Modello
vehicleType: vehicleTypeEnum("vehicle_type").notNull(), vehicleType: vehicleTypeEnum("vehicle_type").notNull(),
year: integer("year"), // Anno immatricolazione year: integer("year"), // Anno immatricolazione
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza
// Assegnazione // Assegnazione
assignedGuardId: varchar("assigned_guard_id").references(() => guards.id, { onDelete: "set null" }), assignedGuardId: varchar("assigned_guard_id").references(() => guards.id, { onDelete: "set null" }),
@ -181,7 +173,6 @@ export const sites = pgTable("sites", {
name: varchar("name").notNull(), name: varchar("name").notNull(),
address: varchar("address").notNull(), address: varchar("address").notNull(),
clientId: varchar("client_id").references(() => users.id), clientId: varchar("client_id").references(() => users.id),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
// Service requirements // Service requirements
shiftType: shiftTypeEnum("shift_type").notNull(), shiftType: shiftTypeEnum("shift_type").notNull(),
@ -309,11 +300,6 @@ export const contractParameters = pgTable("contract_parameters", {
// Pause obbligatorie // Pause obbligatorie
pauseMinutesIfOver6Hours: integer("pause_minutes_if_over_6_hours").notNull().default(10), pauseMinutesIfOver6Hours: integer("pause_minutes_if_over_6_hours").notNull().default(10),
// Buoni pasto
mealVoucherEnabled: boolean("meal_voucher_enabled").default(true), // Buoni pasto attivi
mealVoucherAfterHours: integer("meal_voucher_after_hours").default(6), // Ore minime per diritto buono pasto
mealVoucherAmount: integer("meal_voucher_amount").default(8), // Importo buono pasto in euro (facoltativo)
// 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),

View File

@ -1,13 +1,7 @@
{ {
"version": "1.0.6", "version": "1.0.5",
"lastUpdate": "2025-10-17T07:44:09.569Z", "lastUpdate": "2025-10-17T06:54:32.904Z",
"changelog": [ "changelog": [
{
"version": "1.0.6",
"date": "2025-10-17",
"type": "patch",
"description": "Deployment automatico v1.0.6"
},
{ {
"version": "1.0.5", "version": "1.0.5",
"date": "2025-10-17", "date": "2025-10-17",