Compare commits
5 Commits
66dc97855e
...
3b2ac3d0cd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b2ac3d0cd | ||
|
|
c66642e0a1 | ||
|
|
db67aa9f61 | ||
|
|
0c702f4dbf | ||
|
|
db860125fc |
@ -28,6 +28,8 @@ export default function Sites() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
const [editingSite, setEditingSite] = useState<Site | null>(null);
|
||||||
|
const [isGeocoding, setIsGeocoding] = useState(false);
|
||||||
|
const [isGeocodingEdit, setIsGeocodingEdit] = useState(false);
|
||||||
|
|
||||||
const { data: sites, isLoading } = useQuery<Site[]>({
|
const { data: sites, isLoading } = useQuery<Site[]>({
|
||||||
queryKey: ["/api/sites"],
|
queryKey: ["/api/sites"],
|
||||||
@ -57,6 +59,8 @@ export default function Sites() {
|
|||||||
contractEndDate: undefined,
|
contractEndDate: undefined,
|
||||||
serviceStartTime: "",
|
serviceStartTime: "",
|
||||||
serviceEndTime: "",
|
serviceEndTime: "",
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -77,6 +81,8 @@ export default function Sites() {
|
|||||||
contractEndDate: undefined,
|
contractEndDate: undefined,
|
||||||
serviceStartTime: "",
|
serviceStartTime: "",
|
||||||
serviceEndTime: "",
|
serviceEndTime: "",
|
||||||
|
latitude: undefined,
|
||||||
|
longitude: undefined,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@ -125,6 +131,80 @@ export default function Sites() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleGeocode = async () => {
|
||||||
|
const address = form.getValues("address");
|
||||||
|
if (!address) {
|
||||||
|
toast({
|
||||||
|
title: "Indirizzo mancante",
|
||||||
|
description: "Inserisci un indirizzo prima di cercare le coordinate",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeocoding(true);
|
||||||
|
try {
|
||||||
|
const result: any = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/geocode",
|
||||||
|
{ address }
|
||||||
|
);
|
||||||
|
|
||||||
|
form.setValue("latitude", result.latitude);
|
||||||
|
form.setValue("longitude", result.longitude);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Coordinate trovate",
|
||||||
|
description: `Indirizzo: ${result.displayName}`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Errore geocodifica",
|
||||||
|
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGeocoding(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGeocodeEdit = async () => {
|
||||||
|
const address = editForm.getValues("address");
|
||||||
|
if (!address) {
|
||||||
|
toast({
|
||||||
|
title: "Indirizzo mancante",
|
||||||
|
description: "Inserisci un indirizzo prima di cercare le coordinate",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsGeocodingEdit(true);
|
||||||
|
try {
|
||||||
|
const result: any = await apiRequest(
|
||||||
|
"POST",
|
||||||
|
"/api/geocode",
|
||||||
|
{ address }
|
||||||
|
);
|
||||||
|
|
||||||
|
editForm.setValue("latitude", result.latitude);
|
||||||
|
editForm.setValue("longitude", result.longitude);
|
||||||
|
|
||||||
|
toast({
|
||||||
|
title: "Coordinate trovate",
|
||||||
|
description: `Indirizzo: ${result.displayName}`,
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
toast({
|
||||||
|
title: "Errore geocodifica",
|
||||||
|
description: error.message || "Impossibile trovare le coordinate per questo indirizzo",
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setIsGeocodingEdit(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmit = (data: InsertSite) => {
|
const onSubmit = (data: InsertSite) => {
|
||||||
createMutation.mutate(data);
|
createMutation.mutate(data);
|
||||||
};
|
};
|
||||||
@ -151,6 +231,8 @@ export default function Sites() {
|
|||||||
contractEndDate: site.contractEndDate || undefined,
|
contractEndDate: site.contractEndDate || undefined,
|
||||||
serviceStartTime: site.serviceStartTime || "",
|
serviceStartTime: site.serviceStartTime || "",
|
||||||
serviceEndTime: site.serviceEndTime || "",
|
serviceEndTime: site.serviceEndTime || "",
|
||||||
|
latitude: site.latitude || undefined,
|
||||||
|
longitude: site.longitude || undefined,
|
||||||
isActive: site.isActive,
|
isActive: site.isActive,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -234,6 +316,69 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
Coordinate GPS (per mappa)
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGeocode}
|
||||||
|
disabled={isGeocoding || !form.watch("address")}
|
||||||
|
data-testid="button-geocode"
|
||||||
|
>
|
||||||
|
{isGeocoding ? "Ricerca in corso..." : "📍 Trova Coordinate"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="latitude"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Latitudine</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="41.9028"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
data-testid="input-latitude"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="longitude"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Longitudine</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="12.4964"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
data-testid="input-longitude"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="customerId"
|
name="customerId"
|
||||||
@ -521,6 +666,69 @@ export default function Sites() {
|
|||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="border rounded-lg p-4 space-y-4 bg-muted/50">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<MapPin className="h-4 w-4" />
|
||||||
|
Coordinate GPS (per mappa)
|
||||||
|
</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleGeocodeEdit}
|
||||||
|
disabled={isGeocodingEdit || !editForm.watch("address")}
|
||||||
|
data-testid="button-geocode-edit"
|
||||||
|
>
|
||||||
|
{isGeocodingEdit ? "Ricerca in corso..." : "📍 Trova Coordinate"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="latitude"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Latitudine</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="41.9028"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
data-testid="input-edit-latitude"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
control={editForm.control}
|
||||||
|
name="longitude"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Longitudine</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="12.4964"
|
||||||
|
{...field}
|
||||||
|
value={field.value || ""}
|
||||||
|
data-testid="input-edit-longitude"
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Le coordinate GPS permettono di visualizzare il sito sulla mappa in Planning Mobile
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={editForm.control}
|
control={editForm.control}
|
||||||
name="customerId"
|
name="customerId"
|
||||||
|
|||||||
Binary file not shown.
BIN
database-backups/vigilanzaturni_v1.0.41_20251023_134119.sql.gz
Normal file
BIN
database-backups/vigilanzaturni_v1.0.41_20251023_134119.sql.gz
Normal file
Binary file not shown.
19
replit.md
19
replit.md
@ -54,6 +54,22 @@ The database includes tables for `users`, `guards`, `certifications`, `sites`, `
|
|||||||
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)
|
## Recent Changes (October 2025)
|
||||||
|
### Automatic Geocoding Integration (October 23, 2025)
|
||||||
|
- **Issue**: Users had to manually find and enter GPS coordinates for sites, which was time-consuming and error-prone
|
||||||
|
- **Solution**:
|
||||||
|
- **Backend (`server/routes.ts`)**:
|
||||||
|
- Created POST `/api/geocode` endpoint integrating Nominatim API (OpenStreetMap)
|
||||||
|
- Implemented in-memory rate limiter enforcing 1 request/second to comply with Nominatim usage policy
|
||||||
|
- Added compliant User-Agent header: "VigilanzaTurni/1.0 (Security Shift Management System; contact: support@vigilanzaturni.it)"
|
||||||
|
- Returns latitude, longitude, displayName, and full address object
|
||||||
|
- **Frontend (`client/src/pages/sites.tsx`)**:
|
||||||
|
- Added "📍 Trova Coordinate" button in both create and edit site dialogs
|
||||||
|
- Button auto-populates latitude/longitude fields from address
|
||||||
|
- Disabled state when address missing or geocoding in progress
|
||||||
|
- Toast notifications for success (with found address) and error handling
|
||||||
|
- Dedicated section with bg-muted/50 highlighting for GPS coordinates
|
||||||
|
- **Impact**: Users can now automatically geocode site addresses with a single click, ensuring accurate GPS positioning for Planning Mobile map without manual coordinate lookup
|
||||||
|
|
||||||
### Planning Mobile - Leaflet Map Integration (October 23, 2025)
|
### Planning Mobile - Leaflet Map Integration (October 23, 2025)
|
||||||
- **Issue**: Planning Mobile page had errors in backend endpoints and lacked interactive map functionality
|
- **Issue**: Planning Mobile page had errors in backend endpoints and lacked interactive map functionality
|
||||||
- **Solution**:
|
- **Solution**:
|
||||||
@ -88,4 +104,5 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
|
|||||||
- **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).
|
- **Leaflet**: Interactive map library with react-leaflet bindings and OpenStreetMap tiles (free).
|
||||||
|
- **Nominatim**: OpenStreetMap geocoding API for automatic address-to-coordinates conversion (free, rate limited to 1 req/sec).
|
||||||
@ -3303,6 +3303,70 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============= GEOCODING API (Nominatim/OSM) =============
|
||||||
|
|
||||||
|
// Rate limiter semplice per rispettare 1 req/sec di Nominatim
|
||||||
|
let lastGeocodingRequest = 0;
|
||||||
|
|
||||||
|
app.post("/api/geocode", isAuthenticated, async (req: any, res) => {
|
||||||
|
try {
|
||||||
|
const { address } = req.body;
|
||||||
|
|
||||||
|
if (!address || typeof address !== 'string') {
|
||||||
|
return res.status(400).json({ message: "Address parameter required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rispetta rate limit di 1 req/sec
|
||||||
|
const now = Date.now();
|
||||||
|
const timeSinceLastRequest = now - lastGeocodingRequest;
|
||||||
|
if (timeSinceLastRequest < 1000) {
|
||||||
|
const waitTime = 1000 - timeSinceLastRequest;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, waitTime));
|
||||||
|
}
|
||||||
|
lastGeocodingRequest = Date.now();
|
||||||
|
|
||||||
|
// Chiama Nominatim API
|
||||||
|
const nominatimUrl = new URL("https://nominatim.openstreetmap.org/search");
|
||||||
|
nominatimUrl.searchParams.set("q", address);
|
||||||
|
nominatimUrl.searchParams.set("format", "json");
|
||||||
|
nominatimUrl.searchParams.set("limit", "1");
|
||||||
|
nominatimUrl.searchParams.set("addressdetails", "1");
|
||||||
|
|
||||||
|
// Nominatim Usage Policy richiede User-Agent con contatto email
|
||||||
|
// Ref: https://operations.osmfoundation.org/policies/nominatim/
|
||||||
|
const response = await fetch(nominatimUrl.toString(), {
|
||||||
|
headers: {
|
||||||
|
"User-Agent": "VigilanzaTurni/1.0 (Security Shift Management System; contact: support@vigilanzaturni.it)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Nominatim API error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
message: "Indirizzo non trovato. Prova a essere più specifico (es. Via, Città, Italia)"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = data[0];
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
latitude: result.lat,
|
||||||
|
longitude: result.lon,
|
||||||
|
displayName: result.display_name,
|
||||||
|
address: result.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error geocoding address:", error);
|
||||||
|
res.status(500).json({ message: "Errore durante la geocodifica dell'indirizzo" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const httpServer = createServer(app);
|
const httpServer = createServer(app);
|
||||||
return httpServer;
|
return httpServer;
|
||||||
}
|
}
|
||||||
|
|||||||
10
version.json
10
version.json
@ -1,7 +1,13 @@
|
|||||||
{
|
{
|
||||||
"version": "1.0.40",
|
"version": "1.0.41",
|
||||||
"lastUpdate": "2025-10-23T10:49:40.822Z",
|
"lastUpdate": "2025-10-23T13:41:37.302Z",
|
||||||
"changelog": [
|
"changelog": [
|
||||||
|
{
|
||||||
|
"version": "1.0.41",
|
||||||
|
"date": "2025-10-23",
|
||||||
|
"type": "patch",
|
||||||
|
"description": "Deployment automatico v1.0.41"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "1.0.40",
|
"version": "1.0.40",
|
||||||
"date": "2025-10-23",
|
"date": "2025-10-23",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user