Compare commits

..

5 Commits

Author SHA1 Message Date
Marco Lanzara
47fa3104e3 🚀 Release v1.0.6
- Tipo: patch
- Database backup: database-backups/vigilanzaturni_v1.0.6_20251017_074358.sql.gz
- Data: 2025-10-17 07:44:09
2025-10-17 07:44:09 +00:00
marco370
d75bbaaaa4 Improve user experience by fixing a critical issue in the schedule view
Fix an issue where the schedule view would crash when no guards were assigned to a shift.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: full_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
2025-10-17 07:39:40 +00:00
marco370
4a1c21455b Implement contract rules for shift assignments
Add CCNL (Contratto Collettivo Nazionale di Lavoro) validation logic on the server-side to check shift durations against defined contract parameters. This includes updating the client to handle potential violations and display them to the user via an alert dialog. The server now exposes a new API endpoint (/api/shifts/validate-ccnl) for this validation. Additionally, seeding script has been updated to include default contract parameters.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
2025-10-17 07:39:19 +00:00
marco370
34221555d8 Add location filtering and meal voucher settings
Implements multi-location filtering on Dashboard and Shifts pages, adds meal voucher configuration options in Parameters, and introduces multi-location seeding in server/seed.ts.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
2025-10-17 07:25:08 +00:00
marco370
e21b19c0e6 Add multi-location support and meal voucher parameters
Introduce a new enum for locations (Roccapiemonte, Milano, Roma) and update schema definitions for guards, vehicles, and sites to include location. Add meal voucher configuration to contract parameters.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
2025-10-17 07:13:29 +00:00
8 changed files with 685 additions and 40 deletions

View File

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

View File

@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { Loader2, Save, Settings } from "lucide-react";
import type { ContractParameters } from "@shared/schema";
@ -345,6 +346,61 @@ export default function Parameters() {
</CardContent>
</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 */}
<Card>
<CardHeader>

View File

@ -4,12 +4,14 @@ import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@s
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { insertShiftFormSchema } from "@shared/schema";
import { Plus, Calendar, MapPin, Users, Clock, UserPlus, X, Shield, Car, Heart, Flame, Pencil } from "lucide-react";
import { Plus, Calendar, MapPin, Users, Clock, UserPlus, X, Shield, Car, Heart, Flame, Pencil, Building2 } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge";
@ -24,6 +26,9 @@ export default function Shifts() {
const [selectedShift, setSelectedShift] = useState<ShiftWithDetails | null>(null);
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
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[]>({
queryKey: ["/api/shifts"],
@ -37,6 +42,22 @@ export default function Shifts() {
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({
resolver: zodResolver(insertShiftFormSchema),
defaultValues: {
@ -83,6 +104,47 @@ export default function Shifts() {
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({
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId });
@ -163,12 +225,6 @@ export default function Shifts() {
},
});
const handleAssignGuard = (guardId: string) => {
if (selectedShift) {
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const handleRemoveAssignment = (assignmentId: string) => {
removeAssignmentMutation.mutate(assignmentId);
};
@ -238,6 +294,21 @@ export default function Shifts() {
Calendario 24/7 con assegnazione guardie
</p>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2">
<Building2 className="h-5 w-5 text-muted-foreground" />
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
<SelectTrigger className="w-[180px]" data-testid="select-location-shifts">
<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>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button data-testid="button-add-shift">
@ -267,7 +338,7 @@ export default function Shifts() {
</SelectTrigger>
</FormControl>
<SelectContent>
{sites?.map((site) => (
{filteredSites?.map((site) => (
<SelectItem key={site.id} value={site.id}>
{site.name}
</SelectItem>
@ -345,6 +416,7 @@ export default function Shifts() {
</DialogContent>
</Dialog>
</div>
</div>
{shiftsLoading ? (
<div className="space-y-4">
@ -352,9 +424,9 @@ export default function Shifts() {
<Skeleton className="h-32" />
<Skeleton className="h-32" />
</div>
) : shifts && shifts.length > 0 ? (
) : filteredShifts && filteredShifts.length > 0 ? (
<div className="space-y-4">
{shifts.map((shift) => (
{filteredShifts.map((shift) => (
<Card key={shift.id} className="hover-elevate" data-testid={`card-shift-${shift.id}`}>
<CardHeader>
<div className="flex items-start justify-between gap-4">
@ -493,7 +565,7 @@ export default function Shifts() {
<h3 className="font-medium">Guardie Disponibili</h3>
{guards && guards.length > 0 ? (
<div className="space-y-2">
{guards.map((guard) => {
{filteredGuards?.map((guard) => {
const assigned = isGuardAssigned(guard.id);
const canAssign = canGuardBeAssigned(guard);
@ -585,7 +657,7 @@ export default function Shifts() {
</SelectTrigger>
</FormControl>
<SelectContent>
{sites?.map((site) => (
{filteredSites?.map((site) => (
<SelectItem key={site.id} value={site.id}>
{site.name}
</SelectItem>
@ -686,6 +758,43 @@ export default function Shifts() {
</Form>
</DialogContent>
</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>
);
}

View File

@ -1,7 +1,7 @@
# VigilanzaTurni - Sistema Gestione Turni per Istituti di Vigilanza
## 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 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 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.
## User Preferences
- Interfaccia in italiano

View File

@ -4,9 +4,9 @@ import { storage } from "./storage";
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
import { db } from "./db";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema } from "@shared/schema";
import { eq } from "drizzle-orm";
import { differenceInDays } from "date-fns";
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters } from "@shared/schema";
import { eq, and, gte, lte, desc, asc } from "drizzle-orm";
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO } from "date-fns";
// Determina quale sistema auth usare basandosi sull'ambiente
const USE_LOCAL_AUTH = process.env.DOMAIN === "vt.alfacom.it" || !process.env.REPLIT_DOMAINS;
@ -638,6 +638,169 @@ 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 =============
app.post("/api/shift-assignments", isAuthenticated, async (req, res) => {
try {

255
server/seed.ts Normal file
View File

@ -0,0 +1,255 @@
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,6 +85,12 @@ export const vehicleTypeEnum = pgEnum("vehicle_type", [
"suv", // SUV
]);
export const locationEnum = pgEnum("location", [
"roccapiemonte", // Sede Roccapiemonte (Salerno)
"milano", // Sede Milano
"roma", // Sede Roma
]);
// ============= SESSION & AUTH TABLES (Replit Auth) =============
// Session storage table - mandatory for Replit Auth
@ -118,6 +124,7 @@ export const guards = pgTable("guards", {
userId: varchar("user_id").references(() => users.id),
badgeNumber: varchar("badge_number").notNull().unique(),
phoneNumber: varchar("phone_number"),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza
// Skills
isArmed: boolean("is_armed").default(false),
@ -151,6 +158,7 @@ export const vehicles = pgTable("vehicles", {
model: varchar("model").notNull(), // Modello
vehicleType: vehicleTypeEnum("vehicle_type").notNull(),
year: integer("year"), // Anno immatricolazione
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede di appartenenza
// Assegnazione
assignedGuardId: varchar("assigned_guard_id").references(() => guards.id, { onDelete: "set null" }),
@ -173,6 +181,7 @@ export const sites = pgTable("sites", {
name: varchar("name").notNull(),
address: varchar("address").notNull(),
clientId: varchar("client_id").references(() => users.id),
location: locationEnum("location").notNull().default("roccapiemonte"), // Sede gestionale
// Service requirements
shiftType: shiftTypeEnum("shift_type").notNull(),
@ -300,6 +309,11 @@ export const contractParameters = pgTable("contract_parameters", {
// Pause obbligatorie
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)
maxNightHoursPerWeek: integer("max_night_hours_per_week").default(48),

View File

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