diff --git a/.replit b/.replit index c50bc15..e8328f8 100644 --- a/.replit +++ b/.replit @@ -31,6 +31,10 @@ externalPort = 3002 localPort = 43267 externalPort = 3003 +[[ports]] +localPort = 45679 +externalPort = 4200 + [env] PORT = "5000" diff --git a/client/src/pages/general-planning.tsx b/client/src/pages/general-planning.tsx index 0a5ee27..7de2945 100644 --- a/client/src/pages/general-planning.tsx +++ b/client/src/pages/general-planning.tsx @@ -1,12 +1,14 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { format, startOfWeek, addWeeks } from "date-fns"; import { it } from "date-fns/locale"; import { useLocation } from "wouter"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2 } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { @@ -17,6 +19,9 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { queryClient, apiRequest } from "@/lib/queryClient"; +import { useToast } from "@/hooks/use-toast"; +import type { GuardAvailability } from "@shared/schema"; interface GuardWithHours { guardId: string; @@ -65,9 +70,14 @@ interface GeneralPlanningResponse { export default function GeneralPlanning() { const [, navigate] = useLocation(); + const { toast } = useToast(); const [selectedLocation, setSelectedLocation] = useState("roccapiemonte"); const [weekStart, setWeekStart] = useState(() => startOfWeek(new Date(), { weekStartsOn: 1 })); const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null); + + // Form state per creazione turno + const [selectedGuardId, setSelectedGuardId] = useState(""); + const [days, setDays] = useState(1); // Query per dati planning settimanale const { data: planningData, isLoading } = useQuery({ @@ -81,6 +91,61 @@ export default function GeneralPlanning() { }, }); + // Query per guardie disponibili (solo quando dialog è aperto) + const { data: availableGuards, isLoading: isLoadingGuards } = useQuery({ + queryKey: ["/api/guards/availability", format(weekStart, "yyyy-MM-dd"), selectedCell?.siteId, selectedLocation], + queryFn: async () => { + if (!selectedCell) return []; + const response = await fetch( + `/api/guards/availability?weekStart=${format(weekStart, "yyyy-MM-dd")}&siteId=${selectedCell.siteId}&location=${selectedLocation}` + ); + if (!response.ok) throw new Error("Failed to fetch guards availability"); + return response.json(); + }, + enabled: !!selectedCell, // Query attiva solo se dialog è aperto + }); + + // Mutation per creare turno multi-giorno + const createShiftMutation = useMutation({ + mutationFn: async (data: { siteId: string; startDate: string; days: number; guardId: string }) => { + return apiRequest("/api/general-planning/shifts", "POST", data); + }, + onSuccess: () => { + // Invalida cache planning generale + queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] }); + queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] }); + + toast({ + title: "Turno creato", + description: "Il turno è stato creato con successo", + }); + + // Reset form e chiudi dialog + setSelectedGuardId(""); + setDays(1); + setSelectedCell(null); + }, + onError: (error: any) => { + toast({ + title: "Errore", + description: error.message || "Impossibile creare il turno", + variant: "destructive", + }); + }, + }); + + // Handler per submit form creazione turno + const handleCreateShift = () => { + if (!selectedCell || !selectedGuardId) return; + + createShiftMutation.mutate({ + siteId: selectedCell.siteId, + startDate: selectedCell.date, + days, + guardId: selectedGuardId, + }); + }; + // Navigazione settimana const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1)); const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1)); @@ -459,6 +524,90 @@ export default function GeneralPlanning() {

Nessun turno pianificato per questa data

)} + + {/* Form creazione nuovo turno */} +
+
+ + Crea Nuovo Turno +
+ +
+ {/* Select guardia disponibile */} +
+ + {isLoadingGuards ? ( + + ) : ( + + )} + {availableGuards && availableGuards.length > 0 && selectedGuardId && ( +

+ {(() => { + const guard = availableGuards.find(g => g.guardId === selectedGuardId); + return guard ? `Ore assegnate: ${guard.weeklyHoursAssigned}h / ${guard.weeklyHoursMax}h (rimangono ${guard.weeklyHoursRemaining}h)` : ""; + })()} +

+ )} +
+ + {/* Input numero giorni */} +
+ + setDays(Math.max(1, Math.min(7, parseInt(e.target.value) || 1)))} + disabled={createShiftMutation.isPending} + data-testid="input-days" + /> +

+ Il turno verrà creato a partire da {selectedCell && format(new Date(selectedCell.date), "dd/MM/yyyy")} per {days} {days === 1 ? "giorno" : "giorni"} +

+
+ + {/* Bottone crea turno */} + +
+
)} diff --git a/server/routes.ts b/server/routes.ts index ace9353..dd27996 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -4,7 +4,7 @@ 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, contractParameters, vehicles, serviceTypes } from "@shared/schema"; +import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema } from "@shared/schema"; import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm"; import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns"; @@ -294,6 +294,35 @@ export async function registerRoutes(app: Express): Promise { } }); + // Get guards availability for general planning + app.get("/api/guards/availability", isAuthenticated, async (req, res) => { + try { + const { weekStart, siteId, location } = req.query; + + if (!weekStart || !siteId || !location) { + return res.status(400).json({ + message: "Missing required parameters: weekStart, siteId, location" + }); + } + + const weekStartDate = parseISO(weekStart as string); + if (!isValid(weekStartDate)) { + return res.status(400).json({ message: "Invalid weekStart date format" }); + } + + const availability = await storage.getGuardsAvailability( + weekStartDate, + siteId as string, + location as string + ); + + res.json(availability); + } catch (error) { + console.error("Error fetching guards availability:", error); + res.status(500).json({ message: "Failed to fetch guards availability" }); + } + }); + // ============= VEHICLE ROUTES ============= app.get("/api/vehicles", isAuthenticated, async (req, res) => { try { @@ -1042,6 +1071,105 @@ export async function registerRoutes(app: Express): Promise { } }); + // Create multi-day shift from general planning + app.post("/api/general-planning/shifts", isAuthenticated, async (req, res) => { + try { + // Validate request body + const validationResult = createMultiDayShiftSchema.safeParse(req.body); + if (!validationResult.success) { + return res.status(400).json({ + message: "Invalid request data", + errors: validationResult.error.errors + }); + } + + const { siteId, startDate, days, guardId, shiftType } = validationResult.data; + + // Get site to check contract and service details + const site = await storage.getSite(siteId); + if (!site) { + return res.status(404).json({ message: "Site not found" }); + } + + // Get guard to verify it exists + const guard = await storage.getGuard(guardId); + if (!guard) { + return res.status(404).json({ message: "Guard not found" }); + } + + // Pre-validate all dates are within contract period + const startDateParsed = parseISO(startDate); + for (let dayOffset = 0; dayOffset < days; dayOffset++) { + const shiftDate = addDays(startDateParsed, dayOffset); + const shiftDateStr = format(shiftDate, "yyyy-MM-dd"); + + if (site.contractStartDate && site.contractEndDate) { + const contractStart = new Date(site.contractStartDate); + const contractEnd = new Date(site.contractEndDate); + if (shiftDate < contractStart || shiftDate > contractEnd) { + return res.status(400).json({ + message: `Cannot create shift for ${shiftDateStr}: outside contract period` + }); + } + } + } + + // Create shifts atomically in a transaction + const createdShifts = await db.transaction(async (tx) => { + const createdShiftsInTx = []; + + for (let dayOffset = 0; dayOffset < days; dayOffset++) { + const shiftDate = addDays(startDateParsed, dayOffset); + + // Use site service schedule or default 24h + const serviceStart = site.serviceStartTime || "00:00"; + const serviceEnd = site.serviceEndTime || "23:59"; + + const [startHour, startMin] = serviceStart.split(":").map(Number); + const [endHour, endMin] = serviceEnd.split(":").map(Number); + + const shiftStart = new Date(shiftDate); + shiftStart.setHours(startHour, startMin, 0, 0); + + const shiftEnd = new Date(shiftDate); + shiftEnd.setHours(endHour, endMin, 0, 0); + + // If service ends before it starts, it spans midnight (add 1 day to end) + if (shiftEnd <= shiftStart) { + shiftEnd.setDate(shiftEnd.getDate() + 1); + } + + // Create shift in transaction + const [shift] = await tx.insert(shifts).values({ + siteId: site.id, + startTime: shiftStart, + endTime: shiftEnd, + shiftType: shiftType || site.shiftType || "fixed_post", + status: "planned", + }).returning(); + + // Create shift assignment in transaction + await tx.insert(shiftAssignments).values({ + shiftId: shift.id, + guardId: guard.id, + }); + + createdShiftsInTx.push(shift); + } + + return createdShiftsInTx; + }); + + res.json({ + message: `Created ${createdShifts.length} shifts`, + shifts: createdShifts + }); + } catch (error) { + console.error("Error creating multi-day shifts:", error); + res.status(500).json({ message: "Failed to create shifts", error: String(error) }); + } + }); + // ============= CERTIFICATION ROUTES ============= app.post("/api/certifications", isAuthenticated, async (req, res) => { try { diff --git a/server/storage.ts b/server/storage.ts index 8aaa8a3..e6ec88d 100644 --- a/server/storage.ts +++ b/server/storage.ts @@ -54,9 +54,11 @@ import { type InsertServiceType, type CcnlSetting, type InsertCcnlSetting, + type GuardAvailability, } from "@shared/schema"; import { db } from "./db"; -import { eq, and, gte, lte, desc, or } from "drizzle-orm"; +import { eq, and, gte, lte, desc, or, sql as rawSql } from "drizzle-orm"; +import { addDays, differenceInHours, parseISO, formatISO } from "date-fns"; export interface IStorage { // User operations (Replit Auth required) @@ -153,6 +155,9 @@ export interface IStorage { getCcnlSetting(key: string): Promise; upsertCcnlSetting(setting: InsertCcnlSetting): Promise; deleteCcnlSetting(key: string): Promise; + + // General Planning operations + getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise; } export class DatabaseStorage implements IStorage { @@ -181,7 +186,9 @@ export class DatabaseStorage implements IStorage { .update(users) .set({ ...(userData.email && { email: userData.email }), - ...(userData.name && { name: userData.name }), + ...(userData.firstName && { firstName: userData.firstName }), + ...(userData.lastName && { lastName: userData.lastName }), + ...(userData.profileImageUrl && { profileImageUrl: userData.profileImageUrl }), ...(userData.role && { role: userData.role }), updatedAt: new Date(), }) @@ -660,6 +667,87 @@ export class DatabaseStorage implements IStorage { async deleteCcnlSetting(key: string): Promise { await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key)); } + + // General Planning operations + async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise { + const weekEnd = addDays(weekStart, 6); + + // Get max weekly hours from CCNL settings (default 45h) + const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours'); + const maxWeeklyHours = maxHoursSetting ? Number(maxHoursSetting.value) : 45; + + // Get site to check requirements + const site = await this.getSite(siteId); + if (!site) { + return []; + } + + // Get all guards from the same location + const allGuards = await db + .select() + .from(guards) + .where(eq(guards.location, location as any)); + + // Filter guards by site requirements + const eligibleGuards = allGuards.filter(guard => { + if (site.requiresArmed && !guard.isArmed) return false; + if (site.requiresDriverLicense && !guard.hasDriverLicense) return false; + return true; + }); + + // Calculate weekly hours for each guard + const guardsWithHours: GuardAvailability[] = []; + + for (const guard of eligibleGuards) { + // Get all shift assignments for this guard in the week + const assignments = await db + .select({ + shiftId: shiftAssignments.shiftId, + startTime: shifts.startTime, + endTime: shifts.endTime, + }) + .from(shiftAssignments) + .innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id)) + .where( + and( + eq(shiftAssignments.guardId, guard.id), + gte(shifts.startTime, weekStart), + lte(shifts.startTime, weekEnd) + ) + ); + + // Calculate total hours assigned + let weeklyHoursAssigned = 0; + for (const assignment of assignments) { + const hours = differenceInHours(assignment.endTime, assignment.startTime); + weeklyHoursAssigned += hours; + } + + const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned; + + // Only include guards with remaining hours + if (weeklyHoursRemaining > 0) { + const user = guard.userId ? await this.getUser(guard.userId) : undefined; + const guardName = user + ? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A' + : 'N/A'; + + guardsWithHours.push({ + guardId: guard.id, + guardName, + badgeNumber: guard.badgeNumber, + weeklyHoursRemaining, + weeklyHoursAssigned, + weeklyHoursMax: maxWeeklyHours, + }); + } + } + + // Sort by remaining hours (descending) + guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining); + + return guardsWithHours; + } } export const storage = new DatabaseStorage(); diff --git a/shared/schema.ts b/shared/schema.ts index 496b098..9a824b0 100644 --- a/shared/schema.ts +++ b/shared/schema.ts @@ -837,3 +837,28 @@ export type AbsenceWithDetails = Absence & { shift: Shift; })[]; }; + +// ============= DTOs FOR GENERAL PLANNING ============= + +// DTO per disponibilità guardia nella settimana +export const guardAvailabilitySchema = z.object({ + guardId: z.string(), + guardName: z.string(), + badgeNumber: z.string(), + weeklyHoursRemaining: z.number(), + weeklyHoursAssigned: z.number(), + weeklyHoursMax: z.number(), +}); + +export type GuardAvailability = z.infer; + +// DTO per creazione turno multi-giorno dal Planning Generale +export const createMultiDayShiftSchema = z.object({ + siteId: z.string(), + startDate: z.string(), // YYYY-MM-DD + days: z.number().min(1).max(7), + guardId: z.string(), + shiftType: z.enum(["fixed_post", "patrol", "night_inspection", "quick_response"]).optional(), +}); + +export type CreateMultiDayShiftRequest = z.infer;