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
This commit is contained in:
parent
fc63a3a081
commit
897a674eee
@ -1,5 +1,5 @@
|
|||||||
import { useState, useMemo, useRef, useEffect } from "react";
|
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 { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
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 { MapContainer, TileLayer, Marker, Popup, useMap } from 'react-leaflet';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import { useToast } from "@/hooks/use-toast";
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { queryClient } from "@/lib/queryClient";
|
||||||
|
|
||||||
// Fix Leaflet default icon issue with Webpack
|
// Fix Leaflet default icon issue with Webpack
|
||||||
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
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',
|
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 Location = "roccapiemonte" | "milano" | "roma";
|
||||||
|
|
||||||
type MobileSite = {
|
type MobileSite = {
|
||||||
@ -94,6 +107,24 @@ export default function PlanningMobile() {
|
|||||||
enabled: !!selectedLocation && !!selectedDate,
|
enabled: !!selectedLocation && !!selectedDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Query patrol routes esistenti per guardia selezionata e data
|
||||||
|
const { data: existingPatrolRoutes } = useQuery<any[]>({
|
||||||
|
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<Location, string> = {
|
const locationLabels: Record<Location, string> = {
|
||||||
roccapiemonte: "Roccapiemonte",
|
roccapiemonte: "Roccapiemonte",
|
||||||
milano: "Milano",
|
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
|
// Funzione per salvare il turno pattuglia
|
||||||
const handleSavePatrolRoute = () => {
|
const handleSavePatrolRoute = () => {
|
||||||
if (!selectedGuard) {
|
if (!selectedGuard) {
|
||||||
@ -222,19 +276,46 @@ export default function PlanningMobile() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implementare chiamata API per salvare turno
|
// Prepara i dati per il salvataggio
|
||||||
toast({
|
const patrolRouteData = {
|
||||||
title: "Turno pattuglia creato",
|
guardId: selectedGuard.id,
|
||||||
description: `${patrolRoute.length} tappe assegnate a ${selectedGuard.firstName} ${selectedGuard.lastName}`,
|
shiftDate: selectedDate,
|
||||||
});
|
startTime: "08:00", // TODO: permettere all'utente di configurare
|
||||||
|
endTime: "20:00",
|
||||||
setPatrolRoute([]);
|
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(() => {
|
useEffect(() => {
|
||||||
|
// Reset route quando cambia guardia o location
|
||||||
setPatrolRoute([]);
|
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 (
|
return (
|
||||||
<div className="h-full flex flex-col p-6 space-y-6">
|
<div className="h-full flex flex-col p-6 space-y-6">
|
||||||
@ -390,6 +471,7 @@ export default function PlanningMobile() {
|
|||||||
<Marker
|
<Marker
|
||||||
key={site.id}
|
key={site.id}
|
||||||
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
|
||||||
|
icon={isInRoute ? greenIcon : undefined}
|
||||||
eventHandlers={{
|
eventHandlers={{
|
||||||
click: () => handleAddToRoute(site),
|
click: () => handleAddToRoute(site),
|
||||||
}}
|
}}
|
||||||
|
|||||||
179
server/routes.ts
179
server/routes.ts
@ -4,7 +4,7 @@ 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, 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 { 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 { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
@ -3303,6 +3303,183 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= 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) =============
|
// ============= GEOCODING API (Nominatim/OSM) =============
|
||||||
|
|
||||||
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user