From 897a674eee9122bc687156c7778711852efa5f86 Mon Sep 17 00:00:00 2001 From: marco370 <48531002-marco370@users.noreply.replit.com> Date: Thu, 23 Oct 2025 14:58:08 +0000 Subject: [PATCH] Add patrol route planning and display for mobile users Implement a new API endpoint and client-side logic for creating, fetching, and displaying patrol routes, including stop details, on the mobile planning interface. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/ZaT6tFl --- client/src/pages/planning-mobile.tsx | 102 +++++++++++++-- server/routes.ts | 179 ++++++++++++++++++++++++++- 2 files changed, 270 insertions(+), 11 deletions(-) diff --git a/client/src/pages/planning-mobile.tsx b/client/src/pages/planning-mobile.tsx index 0895ca3..572b3b6 100644 --- a/client/src/pages/planning-mobile.tsx +++ b/client/src/pages/planning-mobile.tsx @@ -1,5 +1,5 @@ import { useState, useMemo, useRef, useEffect } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useMutation } from "@tanstack/react-query"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -12,6 +12,8 @@ import { it } from "date-fns/locale"; import { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet'; import L from 'leaflet'; import { useToast } from "@/hooks/use-toast"; +import { apiRequest } from "@/lib/queryClient"; +import { queryClient } from "@/lib/queryClient"; // Fix Leaflet default icon issue with Webpack delete (L.Icon.Default.prototype as any)._getIconUrl; @@ -21,6 +23,17 @@ L.Icon.Default.mergeOptions({ shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', }); +// Custom icon verde per marker selezionati nella patrol route +const greenIcon = new L.Icon({ + iconRetinaUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png', + iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-green.png', + shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png', + iconSize: [25, 41], + iconAnchor: [12, 41], + popupAnchor: [1, -34], + shadowSize: [41, 41] +}); + type Location = "roccapiemonte" | "milano" | "roma"; type MobileSite = { @@ -94,6 +107,24 @@ export default function PlanningMobile() { enabled: !!selectedLocation && !!selectedDate, }); + // Query patrol routes esistenti per guardia selezionata e data + const { data: existingPatrolRoutes } = useQuery({ + queryKey: ["/api/patrol-routes", selectedGuardId, selectedDate, selectedLocation], + queryFn: async () => { + const params = new URLSearchParams(); + if (selectedGuardId && selectedGuardId !== "all") { + params.set("guardId", selectedGuardId); + } + params.set("date", selectedDate); + params.set("location", selectedLocation); + + const response = await fetch(`/api/patrol-routes?${params.toString()}`); + if (!response.ok) throw new Error("Failed to fetch patrol routes"); + return response.json(); + }, + enabled: !!selectedDate && !!selectedLocation, + }); + const locationLabels: Record = { roccapiemonte: "Roccapiemonte", milano: "Milano", @@ -202,6 +233,29 @@ export default function PlanningMobile() { }); }; + // Mutation per salvare patrol route + const savePatrolRouteMutation = useMutation({ + mutationFn: async (data: any) => { + const response = await apiRequest("POST", "/api/patrol-routes", data); + return response.json(); + }, + onSuccess: () => { + toast({ + title: "Turno pattuglia salvato", + description: `${patrolRoute.length} tappe assegnate a ${selectedGuard?.firstName} ${selectedGuard?.lastName}`, + }); + setPatrolRoute([]); + queryClient.invalidateQueries({ queryKey: ["/api/patrol-routes"] }); + }, + onError: (error: any) => { + toast({ + title: "Errore salvataggio", + description: error.message || "Impossibile salvare il turno pattuglia", + variant: "destructive", + }); + }, + }); + // Funzione per salvare il turno pattuglia const handleSavePatrolRoute = () => { if (!selectedGuard) { @@ -222,19 +276,46 @@ export default function PlanningMobile() { return; } - // TODO: Implementare chiamata API per salvare turno - toast({ - title: "Turno pattuglia creato", - description: `${patrolRoute.length} tappe assegnate a ${selectedGuard.firstName} ${selectedGuard.lastName}`, - }); - - setPatrolRoute([]); + // Prepara i dati per il salvataggio + const patrolRouteData = { + guardId: selectedGuard.id, + shiftDate: selectedDate, + startTime: "08:00", // TODO: permettere all'utente di configurare + endTime: "20:00", + location: selectedLocation, + status: "planned", + stops: patrolRoute.map((site) => ({ + siteId: site.id, + })), + }; + + savePatrolRouteMutation.mutate(patrolRouteData); }; - // Reset patrol route quando cambia guardia o location + // Carica patrol route esistente quando si seleziona una guardia useEffect(() => { + // Reset route quando cambia guardia o location setPatrolRoute([]); - }, [selectedGuardId, selectedLocation]); + + // Carica route esistente se c'è una guardia selezionata + if (selectedGuardId && selectedGuardId !== "all" && existingPatrolRoutes && existingPatrolRoutes.length > 0) { + const guardRoute = existingPatrolRoutes.find(r => r.guardId === selectedGuardId); + if (guardRoute && guardRoute.stops && guardRoute.stops.length > 0) { + // Ricostruisci il patrol route dai stops esistenti + const loadedRoute = guardRoute.stops + .sort((a: any, b: any) => a.sequenceOrder - b.sequenceOrder) + .map((stop: any) => mobileSites?.find(s => s.id === stop.siteId)) + .filter((site: any) => site !== undefined) as MobileSite[]; + + setPatrolRoute(loadedRoute); + + toast({ + title: "Turno pattuglia caricato", + description: `${loadedRoute.length} tappe caricate per ${selectedGuard?.firstName} ${selectedGuard?.lastName}`, + }); + } + } + }, [selectedGuardId, selectedLocation, existingPatrolRoutes, mobileSites]); return (
@@ -390,6 +471,7 @@ export default function PlanningMobile() { handleAddToRoute(site), }} diff --git a/server/routes.ts b/server/routes.ts index 4ea7ef4..1d2650a 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, createMultiDayShiftSchema, insertCustomerSchema, customers } from "@shared/schema"; +import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema, insertCustomerSchema, customers, patrolRoutes, patrolRouteStops, insertPatrolRouteSchema } 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"; import { z } from "zod"; @@ -3303,6 +3303,183 @@ export async function registerRoutes(app: Express): Promise { } }); + // ============= PATROL ROUTES API ============= + + // GET patrol routes per guardia e data + app.get("/api/patrol-routes", isAuthenticated, async (req: any, res) => { + try { + const { guardId, date, location } = req.query; + + const conditions = []; + if (guardId && guardId !== "all") { + conditions.push(eq(patrolRoutes.guardId, guardId)); + } + if (date) { + conditions.push(eq(patrolRoutes.shiftDate, date)); + } + if (location) { + conditions.push(eq(patrolRoutes.location, location as any)); + } + + const routes = await db + .select({ + id: patrolRoutes.id, + guardId: patrolRoutes.guardId, + shiftDate: patrolRoutes.shiftDate, + startTime: patrolRoutes.startTime, + endTime: patrolRoutes.endTime, + status: patrolRoutes.status, + location: patrolRoutes.location, + vehicleId: patrolRoutes.vehicleId, + isArmedRoute: patrolRoutes.isArmedRoute, + notes: patrolRoutes.notes, + guardFirstName: guards.firstName, + guardLastName: guards.lastName, + vehiclePlate: vehicles.licensePlate, + }) + .from(patrolRoutes) + .leftJoin(guards, eq(patrolRoutes.guardId, guards.id)) + .leftJoin(vehicles, eq(patrolRoutes.vehicleId, vehicles.id)) + .where(conditions.length > 0 ? and(...conditions) : undefined) + .orderBy(desc(patrolRoutes.shiftDate)); + + // Carica stops per ogni route + const routesWithStops = await Promise.all( + routes.map(async (route) => { + const stops = await db + .select({ + id: patrolRouteStops.id, + siteId: patrolRouteStops.siteId, + siteName: sites.name, + siteAddress: sites.address, + latitude: sites.latitude, + longitude: sites.longitude, + sequenceOrder: patrolRouteStops.sequenceOrder, + estimatedArrivalTime: patrolRouteStops.estimatedArrivalTime, + isCompleted: patrolRouteStops.isCompleted, + notes: patrolRouteStops.notes, + }) + .from(patrolRouteStops) + .leftJoin(sites, eq(patrolRouteStops.siteId, sites.id)) + .where(eq(patrolRouteStops.patrolRouteId, route.id)) + .orderBy(asc(patrolRouteStops.sequenceOrder)); + + return { + ...route, + stops, + }; + }) + ); + + res.json(routesWithStops); + } catch (error) { + console.error("Error fetching patrol routes:", error); + res.status(500).json({ message: "Errore caricamento turni pattuglia" }); + } + }); + + // POST - Crea nuovo patrol route con stops + app.post("/api/patrol-routes", isAuthenticated, async (req: any, res) => { + try { + const routeData = insertPatrolRouteSchema.parse(req.body); + const { stops } = req.body; // Array di siti in sequenza + + // Verifica che non esista già un patrol route per questa guardia/data + const existing = await db + .select() + .from(patrolRoutes) + .where( + and( + eq(patrolRoutes.guardId, routeData.guardId), + eq(patrolRoutes.shiftDate, routeData.shiftDate) + ) + ) + .limit(1); + + if (existing.length > 0) { + return res.status(400).json({ + message: "Esiste già un turno pattuglia per questa guardia in questa data" + }); + } + + // Crea patrol route + const [newRoute] = await db.insert(patrolRoutes).values(routeData).returning(); + + // Crea stops se presenti + if (stops && Array.isArray(stops) && stops.length > 0) { + const stopsData = stops.map((stop: any, index: number) => ({ + patrolRouteId: newRoute.id, + siteId: stop.siteId, + sequenceOrder: index + 1, + estimatedArrivalTime: stop.estimatedArrivalTime || null, + notes: stop.notes || null, + })); + + await db.insert(patrolRouteStops).values(stopsData); + } + + res.json(newRoute); + } catch (error) { + console.error("Error creating patrol route:", error); + res.status(500).json({ message: "Errore creazione turno pattuglia" }); + } + }); + + // PUT - Aggiorna patrol route esistente + app.put("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => { + try { + const { id } = req.params; + const { stops, ...routeData } = req.body; + + // Aggiorna patrol route + const [updated] = await db + .update(patrolRoutes) + .set(routeData) + .where(eq(patrolRoutes.id, id)) + .returning(); + + if (!updated) { + return res.status(404).json({ message: "Turno pattuglia non trovato" }); + } + + // Se ci sono stops, elimina quelli vecchi e inserisci i nuovi + if (stops && Array.isArray(stops)) { + await db.delete(patrolRouteStops).where(eq(patrolRouteStops.patrolRouteId, id)); + + if (stops.length > 0) { + const stopsData = stops.map((stop: any, index: number) => ({ + patrolRouteId: id, + siteId: stop.siteId, + sequenceOrder: index + 1, + estimatedArrivalTime: stop.estimatedArrivalTime || null, + notes: stop.notes || null, + })); + + await db.insert(patrolRouteStops).values(stopsData); + } + } + + res.json(updated); + } catch (error) { + console.error("Error updating patrol route:", error); + res.status(500).json({ message: "Errore aggiornamento turno pattuglia" }); + } + }); + + // DELETE - Elimina patrol route + app.delete("/api/patrol-routes/:id", isAuthenticated, async (req: any, res) => { + try { + const { id } = req.params; + + await db.delete(patrolRoutes).where(eq(patrolRoutes.id, id)); + + res.json({ message: "Turno pattuglia eliminato" }); + } catch (error) { + console.error("Error deleting patrol route:", error); + res.status(500).json({ message: "Errore eliminazione turno pattuglia" }); + } + }); + // ============= GEOCODING API (Nominatim/OSM) ============= // Rate limiter semplice per rispettare 1 req/sec di Nominatim