Compare commits

..

No commits in common. "66dc97855eb960f868249e90c8a1b8399181903a" and "9ee37d8ea1bebf460171fa448742257c1a7d9a7d" have entirely different histories.

10 changed files with 60 additions and 217 deletions

View File

@ -18,11 +18,6 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Leaflet CSS -->
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@ -1,4 +1,4 @@
import { useState, useMemo } from "react"; import { useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } 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";
@ -9,16 +9,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { Calendar, MapPin, User, Car, Clock } from "lucide-react"; import { Calendar, MapPin, User, Car, Clock } from "lucide-react";
import { format, parseISO, isValid } from "date-fns"; import { format, parseISO, isValid } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import { MapContainer, TileLayer, Marker, Popup } from 'react-leaflet';
import L from 'leaflet';
// Fix Leaflet default icon issue with Webpack
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
});
type Location = "roccapiemonte" | "milano" | "roma"; type Location = "roccapiemonte" | "milano" | "roma";
@ -26,11 +16,12 @@ type MobileSite = {
id: string; id: string;
name: string; name: string;
address: string; address: string;
serviceTypeId: string | null; city: string;
serviceTypeName: string | null; serviceTypeId: string;
serviceTypeName: string;
location: Location; location: Location;
latitude: string | null; latitude: number | null;
longitude: string | null; longitude: number | null;
}; };
type AvailableGuard = { type AvailableGuard = {
@ -86,23 +77,6 @@ export default function PlanningMobile() {
roma: "bg-purple-500", roma: "bg-purple-500",
}; };
// Coordinate di default per centrare la mappa sulla location selezionata
const locationCenters: Record<Location, [number, number]> = {
roccapiemonte: [40.8167, 14.6167], // Roccapiemonte, Salerno
milano: [45.4642, 9.1900], // Milano
roma: [41.9028, 12.4964], // Roma
};
// Filtra siti con coordinate valide per la mappa
const sitesWithCoordinates = useMemo(() => {
return mobileSites?.filter((site) =>
site.latitude !== null &&
site.longitude !== null &&
!isNaN(parseFloat(site.latitude)) &&
!isNaN(parseFloat(site.longitude))
) || [];
}, [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">
{/* Header */} {/* Header */}
@ -180,50 +154,15 @@ export default function PlanningMobile() {
{mobileSites?.length || 0} siti con servizi mobili in {locationLabels[selectedLocation]} {mobileSites?.length || 0} siti con servizi mobili in {locationLabels[selectedLocation]}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex-1 p-0 overflow-hidden"> <CardContent className="flex-1 flex items-center justify-center bg-muted/20 rounded-lg">
{sitesWithCoordinates.length > 0 ? ( <div className="text-center space-y-2">
<MapContainer <MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
key={selectedLocation} <p className="text-sm text-muted-foreground">
center={locationCenters[selectedLocation]} Integrazione mappa in sviluppo
zoom={12} <br />
className="h-full w-full min-h-[400px]" (Leaflet/Google Maps)
data-testid="map-container" </p>
> </div>
<TileLayer
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
/>
{sitesWithCoordinates.map((site) => (
<Marker
key={site.id}
position={[parseFloat(site.latitude!), parseFloat(site.longitude!)]}
>
<Popup>
<div className="space-y-1">
<h4 className="font-semibold text-sm">{site.name}</h4>
<p className="text-xs text-muted-foreground">{site.address}</p>
{site.serviceTypeName && (
<Badge variant="outline" className="text-xs">
{site.serviceTypeName}
</Badge>
)}
</div>
</Popup>
</Marker>
))}
</MapContainer>
) : (
<div className="h-full flex items-center justify-center bg-muted/20">
<div className="text-center space-y-2 p-6">
<MapPin className="h-12 w-12 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Nessun sito con coordinate GPS disponibile
<br />
<span className="text-xs">Aggiungi latitudine e longitudine ai siti per visualizzarli sulla mappa</span>
</p>
</div>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
@ -253,20 +192,18 @@ export default function PlanningMobile() {
<h4 className="font-semibold">{site.name}</h4> <h4 className="font-semibold">{site.name}</h4>
<p className="text-sm text-muted-foreground flex items-center gap-1"> <p className="text-sm text-muted-foreground flex items-center gap-1">
<MapPin className="h-3 w-3" /> <MapPin className="h-3 w-3" />
{site.address} {site.address}, {site.city}
</p> </p>
</div> </div>
<Badge className={locationColors[site.location]} data-testid={`badge-location-${site.id}`}> <Badge className={locationColors[site.location]} data-testid={`badge-location-${site.id}`}>
{locationLabels[site.location]} {locationLabels[site.location]}
</Badge> </Badge>
</div> </div>
{site.serviceTypeName && ( <div className="flex items-center gap-2 text-sm">
<div className="flex items-center gap-2 text-sm"> <Badge variant="outline" data-testid={`badge-service-${site.id}`}>
<Badge variant="outline" data-testid={`badge-service-${site.id}`}> {site.serviceTypeName}
{site.serviceTypeName} </Badge>
</Badge> </div>
</div>
)}
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button size="sm" variant="default" data-testid={`button-assign-${site.id}`}> <Button size="sm" variant="default" data-testid={`button-assign-${site.id}`}>
Assegna Guardia Assegna Guardia

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { Site, InsertSite, Customer, ServiceType } from "@shared/schema"; import { Site, InsertSite, Customer } from "@shared/schema";
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";
@ -18,6 +18,13 @@ import { StatusBadge } from "@/components/status-badge";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
const shiftTypeLabels: Record<string, string> = {
fixed_post: "Presidio Fisso",
patrol: "Pattugliamento",
night_inspection: "Ispettorato Notturno",
quick_response: "Pronto Intervento",
};
const locationLabels: Record<string, string> = { const locationLabels: Record<string, string> = {
roccapiemonte: "Roccapiemonte", roccapiemonte: "Roccapiemonte",
milano: "Milano", milano: "Milano",
@ -37,10 +44,6 @@ export default function Sites() {
queryKey: ["/api/customers"], queryKey: ["/api/customers"],
}); });
const { data: serviceTypes } = useQuery<ServiceType[]>({
queryKey: ["/api/service-types"],
});
const form = useForm<InsertSite>({ const form = useForm<InsertSite>({
resolver: zodResolver(insertSiteSchema), resolver: zodResolver(insertSiteSchema),
defaultValues: { defaultValues: {
@ -48,7 +51,7 @@ export default function Sites() {
address: "", address: "",
customerId: undefined, customerId: undefined,
location: "roccapiemonte", location: "roccapiemonte",
serviceTypeId: undefined, shiftType: "fixed_post",
minGuards: 1, minGuards: 1,
requiresArmed: false, requiresArmed: false,
requiresDriverLicense: false, requiresDriverLicense: false,
@ -68,7 +71,7 @@ export default function Sites() {
address: "", address: "",
customerId: undefined, customerId: undefined,
location: "roccapiemonte", location: "roccapiemonte",
serviceTypeId: undefined, shiftType: "fixed_post",
minGuards: 1, minGuards: 1,
requiresArmed: false, requiresArmed: false,
requiresDriverLicense: false, requiresDriverLicense: false,
@ -142,7 +145,7 @@ export default function Sites() {
address: site.address, address: site.address,
customerId: site.customerId ?? undefined, customerId: site.customerId ?? undefined,
location: site.location, location: site.location,
serviceTypeId: site.serviceTypeId ?? undefined, shiftType: site.shiftType,
minGuards: site.minGuards, minGuards: site.minGuards,
requiresArmed: site.requiresArmed, requiresArmed: site.requiresArmed,
requiresDriverLicense: site.requiresDriverLicense, requiresDriverLicense: site.requiresDriverLicense,
@ -342,22 +345,21 @@ export default function Sites() {
<FormField <FormField
control={form.control} control={form.control}
name="serviceTypeId" name="shiftType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel> <FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}> <Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormControl> <FormControl>
<SelectTrigger data-testid="select-service-type"> <SelectTrigger data-testid="select-shift-type">
<SelectValue placeholder="Seleziona tipo servizio" /> <SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{serviceTypes?.filter(st => st.isActive).map((serviceType) => ( <SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem key={serviceType.id} value={serviceType.id}> <SelectItem value="patrol">Pattugliamento</SelectItem>
{serviceType.label} <SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
</SelectItem> <SelectItem value="quick_response">Pronto Intervento</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -629,22 +631,21 @@ export default function Sites() {
<FormField <FormField
control={editForm.control} control={editForm.control}
name="serviceTypeId" name="shiftType"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Tipologia Servizio (opzionale)</FormLabel> <FormLabel>Tipologia Servizio</FormLabel>
<Select onValueChange={field.onChange} value={field.value ?? undefined}> <Select onValueChange={field.onChange} value={field.value ?? undefined}>
<FormControl> <FormControl>
<SelectTrigger data-testid="select-edit-service-type"> <SelectTrigger data-testid="select-edit-shift-type">
<SelectValue placeholder="Seleziona tipo servizio" /> <SelectValue placeholder="Seleziona tipo servizio" />
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{serviceTypes?.filter(st => st.isActive).map((serviceType) => ( <SelectItem value="fixed_post">Presidio Fisso</SelectItem>
<SelectItem key={serviceType.id} value={serviceType.id}> <SelectItem value="patrol">Pattugliamento</SelectItem>
{serviceType.label} <SelectItem value="night_inspection">Ispettorato Notturno</SelectItem>
</SelectItem> <SelectItem value="quick_response">Pronto Intervento</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage /> <FormMessage />
@ -823,14 +824,9 @@ export default function Sites() {
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{site.serviceTypeId && serviceTypes && (() => { <Badge variant="outline">
const serviceType = serviceTypes.find(st => st.id === site.serviceTypeId); {shiftTypeLabels[site.shiftType]}
return serviceType ? ( </Badge>
<Badge variant="outline" data-testid={`badge-service-type-${site.id}`}>
{serviceType.label}
</Badge>
) : null;
})()}
{(() => { {(() => {
const status = getContractStatus(site); const status = getContractStatus(site);
const statusInfo = contractStatusLabels[status]; const statusInfo = contractStatusLabels[status];

49
package-lock.json generated
View File

@ -41,7 +41,6 @@
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/leaflet": "^1.9.21",
"@types/memoizee": "^0.4.12", "@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -57,7 +56,6 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
@ -71,7 +69,6 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",
@ -2793,17 +2790,6 @@
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz",
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==" "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="
}, },
"node_modules/@react-leaflet/core": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-2.1.0.tgz",
"integrity": "sha512-Qk7Pfu8BSarKGqILj4x7bCSZ1pjuAPZ+qmRwH5S7mDS91VSbVVsJSrW4qA+GPrro8t69gFYVMWb1Zc4yFmPiVg==",
"license": "Hippocratic-2.1",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/@replit/vite-plugin-cartographer": { "node_modules/@replit/vite-plugin-cartographer": {
"version": "0.3.1", "version": "0.3.1",
"resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz", "resolved": "https://registry.npmjs.org/@replit/vite-plugin-cartographer/-/vite-plugin-cartographer-0.3.1.tgz",
@ -3585,12 +3571,6 @@
"@types/express": "*" "@types/express": "*"
} }
}, },
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"license": "MIT"
},
"node_modules/@types/http-errors": { "node_modules/@types/http-errors": {
"version": "2.0.4", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz",
@ -3598,15 +3578,6 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/leaflet": {
"version": "1.9.21",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz",
"integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==",
"license": "MIT",
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/memoizee": { "node_modules/@types/memoizee": {
"version": "0.4.12", "version": "0.4.12",
"resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz", "resolved": "https://registry.npmjs.org/@types/memoizee/-/memoizee-0.4.12.tgz",
@ -5578,12 +5549,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
"license": "BSD-2-Clause"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.29.2", "version": "1.29.2",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.29.2.tgz",
@ -6855,20 +6820,6 @@
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/react-leaflet": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-4.2.1.tgz",
"integrity": "sha512-p9chkvhcKrWn/H/1FFeVSqLdReGwn2qmiobOQGO3BifX+/vV/39qhY8dGqbdcPh1e6jxh/QHriLXr7a4eLFK4Q==",
"license": "Hippocratic-2.1",
"dependencies": {
"@react-leaflet/core": "^2.1.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^18.0.0",
"react-dom": "^18.0.0"
}
},
"node_modules/react-refresh": { "node_modules/react-refresh": {
"version": "0.17.0", "version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",

View File

@ -43,7 +43,6 @@
"@radix-ui/react-tooltip": "^1.2.0", "@radix-ui/react-tooltip": "^1.2.0",
"@tanstack/react-query": "^5.60.5", "@tanstack/react-query": "^5.60.5",
"@types/bcrypt": "^6.0.0", "@types/bcrypt": "^6.0.0",
"@types/leaflet": "^1.9.21",
"@types/memoizee": "^0.4.12", "@types/memoizee": "^0.4.12",
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@ -59,7 +58,6 @@
"express-session": "^1.18.1", "express-session": "^1.18.1",
"framer-motion": "^11.13.1", "framer-motion": "^11.13.1",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
"leaflet": "^1.9.4",
"lucide-react": "^0.453.0", "lucide-react": "^0.453.0",
"memoizee": "^0.4.17", "memoizee": "^0.4.17",
"memorystore": "^1.6.7", "memorystore": "^1.6.7",
@ -73,7 +71,6 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.55.0", "react-hook-form": "^7.55.0",
"react-icons": "^5.4.0", "react-icons": "^5.4.0",
"react-leaflet": "^4.2.1",
"react-resizable-panels": "^2.1.7", "react-resizable-panels": "^2.1.7",
"recharts": "^2.15.2", "recharts": "^2.15.2",
"tailwind-merge": "^2.6.0", "tailwind-merge": "^2.6.0",

View File

@ -19,7 +19,6 @@ VigilanzaTurni is a professional 24/7 shift management system for security compa
- **Autenticazione**: Replit Auth (OIDC) - **Autenticazione**: Replit Auth (OIDC)
- **State Management**: TanStack Query v5 - **State Management**: TanStack Query v5
- **Routing**: Wouter - **Routing**: Wouter
- **Maps**: Leaflet + react-leaflet + OpenStreetMap tiles
### Design System ### Design System
- **Font Principale**: Inter (sans-serif) - **Font Principale**: Inter (sans-serif)
@ -29,18 +28,18 @@ VigilanzaTurni is a professional 24/7 shift management system for security compa
- **Componenti**: Shadcn UI with an operational design. - **Componenti**: Shadcn UI with an operational design.
### Database Schema ### Database Schema
The database includes tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, `notifications`, `customers`, `service_types`, and various tables for advanced scheduling and constraints (`guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, `absence_affected_shifts`). Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`. Sites support multi-location (`location` field), contract management (`contractReference`, `contractStartDate`, `contractEndDate`), and service type association (`serviceTypeId` FK to `service_types.id`). The database includes tables for `users`, `guards`, `certifications`, `sites`, `shifts`, `shift_assignments`, `notifications`, `customers`, and various tables for advanced scheduling and constraints (`guard_constraints`, `site_preferences`, `contract_parameters`, `training_courses`, `holidays`, `holiday_assignments`, `absences`, `absence_affected_shifts`). Service types include specialized parameters like `fixedPostHours`, `patrolPassages`, `inspectionFrequency`, and `responseTimeMinutes`. Sites support multi-location (`location` field) and contract management (`contractReference`, `contractStartDate`, `contractEndDate`).
### Core Features ### Core Features
- **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering sites, guards, and vehicles by selected branch. - **Multi-Sede Operational Planning**: Location-first approach for shift planning, filtering sites, guards, and vehicles by selected branch.
- **Service Type Classification**: Service types are classified as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules (Planning Fissi, Planning Mobile). - **Service Type Classification**: Service types are classified as "fisso" (fixed posts) or "mobile" (patrols, inspections) to route sites to appropriate planning modules (Planning Fissi, Planning Mobile).
- **Planning Fissi**: Weekly planning grid showing all sites with active contracts, allowing direct shift creation for multiple days with guard availability checks. - **Planning Fissi**: Weekly planning grid showing all sites with active contracts, allowing direct shift creation for multiple days with guard availability checks.
- **Planning Mobile**: Dedicated guard-centric interface for mobile services, displaying guard availability and hours for mobile-classified sites. Includes interactive Leaflet map showing sites with GPS coordinates and automatic re-centering based on selected location. - **Planning Mobile**: Dedicated guard-centric interface for mobile services, displaying guard availability and hours for mobile-classified sites. Includes a map placeholder for future integration.
- **Customer Management**: Full CRUD operations for customers with comprehensive details. - **Customer Management**: Full CRUD operations for customers with comprehensive details.
- **Customer-Centric Reports**: New reports aggregating data by customer, replacing site-based billing, with specific counters for fixed posts (hours), patrols (passages), inspections, and interventions. CSV export is supported. - **Customer-Centric Reports**: New reports aggregating data by customer, replacing site-based billing, with specific counters for fixed posts (hours), patrols (passages), inspections, and interventions. CSV export is supported.
- **Dashboard Operativa**: Live KPIs and real-time shift status. - **Dashboard Operativa**: Live KPIs and real-time shift status.
- **Gestione Guardie**: Complete profiles with skill matrix, certification management, and unique badge numbers. - **Gestione Guardie**: Complete profiles with skill matrix, certification management, and unique badge numbers.
- **Gestione Siti/Commesse**: Sites are associated with service types from the `service_types` table via `serviceTypeId` (FK). Service types are managed in the "Tipologie Servizi" page and include specialized parameters. Sites include service schedule, contract management, location assignment, and customer assignment (`customerId` FK to `customers.id`). - **Gestione Siti/Commesse**: Service types with specialized parameters and minimum requirements. Sites include service schedule, contract management, and location assignment.
- **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses. - **Pianificazione Turni**: 24/7 calendar, manual guard assignment, basic constraints, and shift statuses.
- **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences. - **Advanced Planning**: Management of guard constraints, site preferences, contract parameters, training courses, holidays, and absences.
@ -53,32 +52,6 @@ The database includes tables for `users`, `guards`, `certifications`, `sites`, `
### Critical Date/Timezone Handling ### Critical Date/Timezone Handling
To prevent timezone-related bugs, especially when assigning shifts, dates should always be constructed from components (`new Date(year, month-1, day)`) and never parsed from ISO strings directly using `parseISO()` or `new Date(string ISO)`. Date validation should use regex instead of `parseISO()`. To prevent timezone-related bugs, especially when assigning shifts, dates should always be constructed from components (`new Date(year, month-1, day)`) and never parsed from ISO strings directly using `parseISO()` or `new Date(string ISO)`. Date validation should use regex instead of `parseISO()`.
## Recent Changes (October 2025)
### Planning Mobile - Leaflet Map Integration (October 23, 2025)
- **Issue**: Planning Mobile page had errors in backend endpoints and lacked interactive map functionality
- **Solution**:
- **Backend Fixes**:
- Removed non-existent fields (`city` from sites, `isActive` from guards) from queries
- Changed `innerJoin` to `leftJoin` for service types to handle sites without serviceTypeId
- Fixed `orderBy` syntax (single field instead of multiple)
- Added `hasDriverLicense` filter for guards (mobile services require driving)
- **Map Integration**:
- Implemented Leaflet + react-leaflet with OpenStreetMap tiles (100% free, no API key)
- Interactive markers for sites with GPS coordinates and classification="mobile"
- Popup details showing site name, address, and service type
- Automatic re-centering via `key={selectedLocation}` forcing MapContainer remount
- Graceful fallback for sites without coordinates
- **Impact**: Planning Mobile now fully functional with interactive map for patrol/inspection route planning
### Sites Form Fix - ServiceTypeId Integration (October 2025)
- **Issue**: Sites form used hardcoded `shiftType` enum values instead of dynamic service types from the database
- **Solution**:
- Changed Sites form to use `serviceTypeId` (FK to `service_types.id`) instead of deprecated `shiftType` field
- Added dynamic service type dropdown loading from `/api/service-types` endpoint
- Updated both create and edit forms to properly handle service type selection
- Card display now shows service type label from database instead of hardcoded labels
- **Impact**: Sites now correctly reference service types configured in "Tipologie Servizi" page, ensuring consistency across the system
## External Dependencies ## External Dependencies
- **Replit Auth**: For OpenID Connect (OIDC) based authentication. - **Replit Auth**: For OpenID Connect (OIDC) based authentication.
- **Neon**: Managed PostgreSQL database service. - **Neon**: Managed PostgreSQL database service.
@ -87,5 +60,4 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
- **Zod**: For schema validation. - **Zod**: For schema validation.
- **TanStack Query**: For data fetching and state management. - **TanStack Query**: For data fetching and state management.
- **Wouter**: For client-side routing. - **Wouter**: For client-side routing.
- **date-fns**: For date manipulation and formatting. - **date-fns**: For date manipulation and formatting.
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles (free).

View File

@ -3191,6 +3191,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
id: sites.id, id: sites.id,
name: sites.name, name: sites.name,
address: sites.address, address: sites.address,
city: sites.city,
serviceTypeId: sites.serviceTypeId, serviceTypeId: sites.serviceTypeId,
serviceTypeName: serviceTypes.label, serviceTypeName: serviceTypes.label,
location: sites.location, location: sites.location,
@ -3198,7 +3199,7 @@ export async function registerRoutes(app: Express): Promise<Server> {
longitude: sites.longitude, longitude: sites.longitude,
}) })
.from(sites) .from(sites)
.leftJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id)) .innerJoin(serviceTypes, eq(sites.serviceTypeId, serviceTypes.id))
.where( .where(
and( and(
eq(sites.location, location as "roccapiemonte" | "milano" | "roma"), eq(sites.location, location as "roccapiemonte" | "milano" | "roma"),
@ -3234,17 +3235,17 @@ export async function registerRoutes(app: Express): Promise<Server> {
return res.status(400).json({ message: "Invalid date format (use YYYY-MM-DD)" }); return res.status(400).json({ message: "Invalid date format (use YYYY-MM-DD)" });
} }
// Ottieni tutte le guardie per location CHE HANNO LA PATENTE // Ottieni tutte le guardie per location
const allGuards = await db const allGuards = await db
.select() .select()
.from(guards) .from(guards)
.where( .where(
and( and(
eq(guards.location, location as "roccapiemonte" | "milano" | "roma"), eq(guards.location, location as "roccapiemonte" | "milano" | "roma"),
eq(guards.hasDriverLicense, true) eq(guards.isActive, true)
) )
) )
.orderBy(guards.lastName); .orderBy(guards.lastName, guards.firstName);
// Calcola settimana corrente per calcolare ore settimanali // Calcola settimana corrente per calcolare ore settimanali
const [year, month, day] = date.split("-").map(Number); const [year, month, day] = date.split("-").map(Number);

View File

@ -1,13 +1,7 @@
{ {
"version": "1.0.40", "version": "1.0.39",
"lastUpdate": "2025-10-23T10:49:40.822Z", "lastUpdate": "2025-10-23T10:18:17.289Z",
"changelog": [ "changelog": [
{
"version": "1.0.40",
"date": "2025-10-23",
"type": "patch",
"description": "Deployment automatico v1.0.40"
},
{ {
"version": "1.0.39", "version": "1.0.39",
"date": "2025-10-23", "date": "2025-10-23",