Compare commits
No commits in common. "47fa3104e3022b94c04de438d21bc3bebe5b3e3c" and "2bf8b54dc605946985eaf888c932c95a53c3d2ee" have entirely different histories.
47fa3104e3
...
2bf8b54dc6
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
169
server/routes.ts
169
server/routes.ts
@ -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 {
|
||||||
|
|||||||
255
server/seed.ts
255
server/seed.ts
@ -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);
|
|
||||||
});
|
|
||||||
@ -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),
|
||||||
|
|
||||||
|
|||||||
10
version.json
10
version.json
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user