Introduce new data structures and API endpoints for mobile patrol routes, differentiating them from fixed guard shifts in the service planning interface. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/rjLU1aT
452 lines
19 KiB
TypeScript
452 lines
19 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { format, addWeeks, addDays, startOfWeek } from "date-fns";
|
|
import { it } from "date-fns/locale";
|
|
import { ChevronLeft, ChevronRight, Users, Building2, Navigation, Shield, Car as CarIcon, MapPin } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
|
|
type Location = "roccapiemonte" | "milano" | "roma";
|
|
|
|
interface FixedShiftDetail {
|
|
shiftId: string;
|
|
date: string;
|
|
from: string;
|
|
to: string;
|
|
siteName: string;
|
|
siteAddress: string;
|
|
siteId: string;
|
|
isArmed: boolean;
|
|
vehicle?: {
|
|
licensePlate: string;
|
|
brand: string;
|
|
model: string;
|
|
};
|
|
hours: number;
|
|
}
|
|
|
|
interface FixedGuardSchedule {
|
|
guardId: string;
|
|
guardName: string;
|
|
badgeNumber: string;
|
|
shifts: FixedShiftDetail[];
|
|
totalHours: number;
|
|
}
|
|
|
|
interface PatrolRoute {
|
|
routeId: string;
|
|
guardId: string;
|
|
shiftDate: string;
|
|
startTime: string;
|
|
endTime: string;
|
|
isArmedRoute: boolean;
|
|
vehicle?: {
|
|
licensePlate: string;
|
|
brand: string;
|
|
model: string;
|
|
};
|
|
stops: {
|
|
siteId: string;
|
|
siteName: string;
|
|
siteAddress: string;
|
|
sequenceOrder: number;
|
|
}[];
|
|
}
|
|
|
|
interface MobileGuardSchedule {
|
|
guardId: string;
|
|
guardName: string;
|
|
badgeNumber: string;
|
|
routes: PatrolRoute[];
|
|
totalRoutes: number;
|
|
}
|
|
|
|
interface SiteSchedule {
|
|
siteId: string;
|
|
siteName: string;
|
|
location: string;
|
|
shifts: {
|
|
shiftId: string;
|
|
date: string;
|
|
from: string;
|
|
to: string;
|
|
guards: {
|
|
guardName: string;
|
|
badgeNumber: string;
|
|
hours: number;
|
|
isArmed: boolean;
|
|
}[];
|
|
vehicle?: {
|
|
licensePlate: string;
|
|
brand: string;
|
|
model: string;
|
|
};
|
|
totalGuards: number;
|
|
totalHours: number;
|
|
}[];
|
|
totalShifts: number;
|
|
totalHours: number;
|
|
}
|
|
|
|
export default function ServicePlanning() {
|
|
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
|
const [weekStart, setWeekStart] = useState<Date>(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
|
const [viewMode, setViewMode] = useState<"guard-fixed" | "guard-mobile" | "site">("guard-fixed");
|
|
|
|
const weekStartStr = format(weekStart, "yyyy-MM-dd");
|
|
|
|
// Query per vista Agenti Fissi
|
|
const { data: fixedGuardSchedules, isLoading: isLoadingFixedGuards } = useQuery<FixedGuardSchedule[]>({
|
|
queryKey: ["/api/service-planning/guards-fixed", weekStartStr, selectedLocation],
|
|
queryFn: async () => {
|
|
const response = await fetch(`/api/service-planning/guards-fixed?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
|
if (!response.ok) throw new Error("Failed to fetch fixed guard schedules");
|
|
return response.json();
|
|
},
|
|
enabled: viewMode === "guard-fixed",
|
|
});
|
|
|
|
// Query per vista Agenti Mobili
|
|
const { data: mobileGuardSchedules, isLoading: isLoadingMobileGuards } = useQuery<MobileGuardSchedule[]>({
|
|
queryKey: ["/api/service-planning/guards-mobile", weekStartStr, selectedLocation],
|
|
queryFn: async () => {
|
|
const response = await fetch(`/api/service-planning/guards-mobile?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
|
if (!response.ok) throw new Error("Failed to fetch mobile guard schedules");
|
|
return response.json();
|
|
},
|
|
enabled: viewMode === "guard-mobile",
|
|
});
|
|
|
|
// Query per vista Siti
|
|
const { data: siteSchedules, isLoading: isLoadingSites } = useQuery<SiteSchedule[]>({
|
|
queryKey: ["/api/service-planning/by-site", weekStartStr, selectedLocation],
|
|
queryFn: async () => {
|
|
const response = await fetch(`/api/service-planning/by-site?weekStart=${weekStartStr}&location=${selectedLocation}`);
|
|
if (!response.ok) throw new Error("Failed to fetch site schedules");
|
|
return response.json();
|
|
},
|
|
enabled: viewMode === "site",
|
|
});
|
|
|
|
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
|
|
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
|
|
|
|
return (
|
|
<div className="h-full overflow-auto p-6 space-y-6">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold">Visione Servizi</h1>
|
|
<p className="text-muted-foreground">
|
|
Visualizza orari e dotazioni per agente fisso, agente mobile o per sito
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controlli */}
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex flex-wrap items-center gap-4">
|
|
{/* Selezione sede */}
|
|
<div className="flex-1 min-w-[200px]">
|
|
<label className="text-sm font-medium mb-2 block">Sede</label>
|
|
<Select value={selectedLocation} onValueChange={(v) => setSelectedLocation(v as Location)}>
|
|
<SelectTrigger data-testid="select-location">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
|
<SelectItem value="milano">Milano</SelectItem>
|
|
<SelectItem value="roma">Roma</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Navigazione settimana */}
|
|
<div className="flex-1 min-w-[300px]">
|
|
<label className="text-sm font-medium mb-2 block">Settimana</label>
|
|
<div className="flex items-center gap-2">
|
|
<Button variant="outline" size="icon" onClick={goToPreviousWeek} data-testid="button-prev-week">
|
|
<ChevronLeft className="h-4 w-4" />
|
|
</Button>
|
|
<div className="flex-1 text-center font-medium">
|
|
{format(weekStart, "d MMM", { locale: it })} - {format(addDays(weekStart, 6), "d MMM yyyy", { locale: it })}
|
|
</div>
|
|
<Button variant="outline" size="icon" onClick={goToNextWeek} data-testid="button-next-week">
|
|
<ChevronRight className="h-4 w-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Tabs per vista */}
|
|
<Tabs value={viewMode} onValueChange={(v) => setViewMode(v as "guard-fixed" | "guard-mobile" | "site")}>
|
|
<TabsList className="grid w-full max-w-2xl grid-cols-3">
|
|
<TabsTrigger value="guard-fixed" data-testid="tab-guard-fixed-view">
|
|
<Users className="h-4 w-4 mr-2" />
|
|
Agenti Fissi
|
|
</TabsTrigger>
|
|
<TabsTrigger value="guard-mobile" data-testid="tab-guard-mobile-view">
|
|
<Navigation className="h-4 w-4 mr-2" />
|
|
Agenti Mobili
|
|
</TabsTrigger>
|
|
<TabsTrigger value="site" data-testid="tab-site-view">
|
|
<Building2 className="h-4 w-4 mr-2" />
|
|
Vista Sito
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Vista Agenti Fissi */}
|
|
<TabsContent value="guard-fixed" className="space-y-4 mt-6">
|
|
{isLoadingFixedGuards ? (
|
|
<div className="space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-32 w-full" />
|
|
))}
|
|
</div>
|
|
) : fixedGuardSchedules && fixedGuardSchedules.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{fixedGuardSchedules.map((guard) => (
|
|
<Card key={guard.guardId} data-testid={`card-guard-fixed-${guard.guardId}`}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">
|
|
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
|
|
</CardTitle>
|
|
<Badge>{guard.totalHours}h totali</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{guard.shifts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nessun turno fisso assegnato</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{guard.shifts.map((shift) => (
|
|
<div
|
|
key={shift.shiftId}
|
|
className="p-3 rounded-md bg-muted/50 space-y-2"
|
|
data-testid={`shift-${shift.shiftId}`}
|
|
>
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-1 flex-1">
|
|
<div className="font-medium">{shift.siteName}</div>
|
|
<div className="text-sm text-muted-foreground flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
{shift.siteAddress}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to} ({shift.hours}h)
|
|
</div>
|
|
</div>
|
|
<div className="flex flex-col items-end gap-1">
|
|
{shift.isArmed && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Shield className="h-3 w-3 mr-1" />
|
|
Armato
|
|
</Badge>
|
|
)}
|
|
{shift.vehicle && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<CarIcon className="h-3 w-3 mr-1" />
|
|
{shift.vehicle.licensePlate}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-center text-muted-foreground">Nessun agente con turni fissi assegnati</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Vista Agenti Mobili */}
|
|
<TabsContent value="guard-mobile" className="space-y-4 mt-6">
|
|
{isLoadingMobileGuards ? (
|
|
<div className="space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-32 w-full" />
|
|
))}
|
|
</div>
|
|
) : mobileGuardSchedules && mobileGuardSchedules.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{mobileGuardSchedules.map((guard) => (
|
|
<Card key={guard.guardId} data-testid={`card-guard-mobile-${guard.guardId}`}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">
|
|
{guard.guardName} <Badge variant="outline">{guard.badgeNumber}</Badge>
|
|
</CardTitle>
|
|
<Badge>{guard.totalRoutes} {guard.totalRoutes === 1 ? 'percorso' : 'percorsi'}</Badge>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{guard.routes.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nessun percorso pattuglia assegnato</p>
|
|
) : (
|
|
<div className="space-y-4">
|
|
{guard.routes.map((route) => (
|
|
<div
|
|
key={route.routeId}
|
|
className="p-3 rounded-md bg-muted/50 space-y-3"
|
|
data-testid={`route-${route.routeId}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="font-medium">
|
|
{format(new Date(route.shiftDate), "EEEE d MMM yyyy", { locale: it })}
|
|
</div>
|
|
<div className="text-sm text-muted-foreground">
|
|
{route.startTime} - {route.endTime}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
{route.isArmedRoute && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Shield className="h-3 w-3 mr-1" />
|
|
Armato
|
|
</Badge>
|
|
)}
|
|
{route.vehicle && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<CarIcon className="h-3 w-3 mr-1" />
|
|
{route.vehicle.licensePlate}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-sm font-medium flex items-center gap-1">
|
|
<Navigation className="h-4 w-4" />
|
|
Percorso ({route.stops.length} {route.stops.length === 1 ? 'tappa' : 'tappe'}):
|
|
</div>
|
|
<div className="space-y-1 pl-5">
|
|
{route.stops.map((stop) => (
|
|
<div key={stop.siteId} className="text-sm text-muted-foreground flex items-start gap-2">
|
|
<Badge variant="secondary" className="text-xs">
|
|
{stop.sequenceOrder}
|
|
</Badge>
|
|
<div className="flex-1">
|
|
<div className="font-medium text-foreground">{stop.siteName}</div>
|
|
<div className="text-xs flex items-center gap-1">
|
|
<MapPin className="h-3 w-3" />
|
|
{stop.siteAddress}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-center text-muted-foreground">Nessun agente con percorsi pattuglia assegnati</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
|
|
{/* Vista Sito */}
|
|
<TabsContent value="site" className="space-y-4 mt-6">
|
|
{isLoadingSites ? (
|
|
<div className="space-y-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<Skeleton key={i} className="h-32 w-full" />
|
|
))}
|
|
</div>
|
|
) : siteSchedules && siteSchedules.length > 0 ? (
|
|
<div className="grid gap-4">
|
|
{siteSchedules.map((site) => (
|
|
<Card key={site.siteId} data-testid={`card-site-${site.siteId}`}>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">{site.siteName}</CardTitle>
|
|
<div className="flex items-center gap-2">
|
|
<Badge variant="outline">{site.totalShifts} turni</Badge>
|
|
<Badge>{site.totalHours}h totali</Badge>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{site.shifts.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground">Nessun turno programmato</p>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{site.shifts.map((shift) => (
|
|
<div
|
|
key={shift.shiftId}
|
|
className="p-3 rounded-md bg-muted/50 space-y-2"
|
|
data-testid={`shift-${shift.shiftId}`}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<div className="font-medium">
|
|
{format(new Date(shift.date), "EEEE d MMM", { locale: it })} • {shift.from} - {shift.to}
|
|
</div>
|
|
<div className="flex gap-1">
|
|
<Badge variant="secondary">{shift.totalGuards} {shift.totalGuards === 1 ? "guardia" : "guardie"}</Badge>
|
|
{shift.vehicle && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<CarIcon className="h-3 w-3 mr-1" />
|
|
{shift.vehicle.licensePlate}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
{shift.guards.map((guard, idx) => (
|
|
<div key={idx} className="text-sm text-muted-foreground flex items-center justify-between">
|
|
<span>{guard.guardName} ({guard.badgeNumber}) - {guard.hours}h</span>
|
|
{guard.isArmed && (
|
|
<Badge variant="outline" className="text-xs">
|
|
<Shield className="h-3 w-3 mr-1" />
|
|
Armato
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<p className="text-center text-muted-foreground">Nessun sito con turni programmati</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|