Add planning consultation views and reorganize sidebar navigation
Introduce new planning consultation pages for fixed and mobile agents, refactor sidebar navigation into logical groups, and enhance shift assignment logic by preventing double-booking of guards. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/kDVJJUd
This commit is contained in:
parent
4a2b5fab66
commit
e0504f0a13
@ -21,15 +21,13 @@ import AdvancedPlanning from "@/pages/advanced-planning";
|
||||
import Vehicles from "@/pages/vehicles";
|
||||
import Parameters from "@/pages/parameters";
|
||||
import Services from "@/pages/services";
|
||||
import Planning from "@/pages/planning";
|
||||
import OperationalPlanning from "@/pages/operational-planning";
|
||||
import GeneralPlanning from "@/pages/general-planning";
|
||||
import ServicePlanning from "@/pages/service-planning";
|
||||
import Customers from "@/pages/customers";
|
||||
import PlanningMobile from "@/pages/planning-mobile";
|
||||
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
||||
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
||||
import SitePlanningView from "@/pages/site-planning-view";
|
||||
import PlanningViewFixedAgent from "@/pages/planning-view-fixed-agent";
|
||||
import PlanningViewMobileAgent from "@/pages/planning-view-mobile-agent";
|
||||
|
||||
function Router() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@ -48,15 +46,13 @@ function Router() {
|
||||
<Route path="/services" component={Services} />
|
||||
<Route path="/vehicles" component={Vehicles} />
|
||||
<Route path="/shifts" component={Shifts} />
|
||||
<Route path="/planning" component={Planning} />
|
||||
<Route path="/operational-planning" component={OperationalPlanning} />
|
||||
<Route path="/general-planning" component={GeneralPlanning} />
|
||||
<Route path="/service-planning" component={ServicePlanning} />
|
||||
<Route path="/planning-mobile" component={PlanningMobile} />
|
||||
<Route path="/advanced-planning" component={AdvancedPlanning} />
|
||||
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
||||
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
||||
<Route path="/site-planning-view" component={SitePlanningView} />
|
||||
<Route path="/planning-view-fixed-agent" component={PlanningViewFixedAgent} />
|
||||
<Route path="/planning-view-mobile-agent" component={PlanningViewMobileAgent} />
|
||||
<Route path="/reports" component={Reports} />
|
||||
<Route path="/notifications" component={Notifications} />
|
||||
<Route path="/users" component={Users} />
|
||||
|
||||
@ -31,55 +31,67 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ThemeToggle } from "@/components/theme-toggle";
|
||||
|
||||
const menuItems = [
|
||||
const dashboardItems = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
icon: Shield,
|
||||
roles: ["admin", "coordinator", "guard", "client"],
|
||||
},
|
||||
];
|
||||
|
||||
const creationItems = [
|
||||
{
|
||||
title: "Turni",
|
||||
title: "Turni Fissi",
|
||||
url: "/shifts",
|
||||
icon: Calendar,
|
||||
roles: ["admin", "coordinator", "guard"],
|
||||
},
|
||||
{
|
||||
title: "Pianificazione",
|
||||
url: "/planning",
|
||||
icon: ClipboardList,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Pianificazione Operativa",
|
||||
url: "/operational-planning",
|
||||
icon: Calendar,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Planning Fissi",
|
||||
url: "/general-planning",
|
||||
icon: BarChart3,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Planning Mobile",
|
||||
title: "Pattuglie Mobile",
|
||||
url: "/planning-mobile",
|
||||
icon: Navigation,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
];
|
||||
|
||||
const consultationItems = [
|
||||
{
|
||||
title: "Planning di Servizio",
|
||||
url: "/service-planning",
|
||||
icon: ClipboardList,
|
||||
title: "Planning Agente Fisso",
|
||||
url: "/planning-view-fixed-agent",
|
||||
icon: Users,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Gestione Pianificazioni",
|
||||
url: "/advanced-planning",
|
||||
icon: ClipboardList,
|
||||
title: "Planning Agente Mobile",
|
||||
url: "/planning-view-mobile-agent",
|
||||
icon: Navigation,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Planning Sito",
|
||||
url: "/site-planning-view",
|
||||
icon: MapPin,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
];
|
||||
|
||||
const personalItems = [
|
||||
{
|
||||
title: "I Miei Turni Fissi",
|
||||
url: "/my-shifts-fixed",
|
||||
icon: Calendar,
|
||||
roles: ["guard"],
|
||||
},
|
||||
{
|
||||
title: "Le Mie Pattuglie",
|
||||
url: "/my-shifts-mobile",
|
||||
icon: Navigation,
|
||||
roles: ["guard"],
|
||||
},
|
||||
];
|
||||
|
||||
const registryItems = [
|
||||
{
|
||||
title: "Guardie",
|
||||
url: "/guards",
|
||||
@ -110,12 +122,18 @@ const menuItems = [
|
||||
icon: Car,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
];
|
||||
|
||||
const reportingItems = [
|
||||
{
|
||||
title: "Report",
|
||||
url: "/reports",
|
||||
icon: BarChart3,
|
||||
roles: ["admin", "coordinator", "client"],
|
||||
},
|
||||
];
|
||||
|
||||
const systemItems = [
|
||||
{
|
||||
title: "Notifiche",
|
||||
url: "/notifications",
|
||||
@ -140,8 +158,26 @@ export function AppSidebar() {
|
||||
const { user } = useAuth();
|
||||
const [location] = useLocation();
|
||||
|
||||
const filteredItems = menuItems.filter(
|
||||
(item) => user && item.roles.includes(user.role)
|
||||
const filterItems = (items: typeof dashboardItems) =>
|
||||
items.filter((item) => user && item.roles.includes(user.role));
|
||||
|
||||
const renderMenuItems = (items: typeof dashboardItems) => (
|
||||
<SidebarMenu>
|
||||
{items.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={location === item.url}
|
||||
data-testid={`link-${item.title.toLowerCase().replace(/\s+/g, "-")}`}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
);
|
||||
|
||||
return (
|
||||
@ -157,27 +193,74 @@ export function AppSidebar() {
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
{/* Dashboard */}
|
||||
{filterItems(dashboardItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Menu Principale</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{filteredItems.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={location === item.url}
|
||||
data-testid={`link-${item.title.toLowerCase()}`}
|
||||
>
|
||||
<Link href={item.url}>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<span>{item.title}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
))}
|
||||
</SidebarMenu>
|
||||
{renderMenuItems(filterItems(dashboardItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Planning Operativo - Creazione */}
|
||||
{filterItems(creationItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Planning - Creazione</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{renderMenuItems(filterItems(creationItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Planning Operativo - Consultazione */}
|
||||
{filterItems(consultationItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Planning - Consultazione</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{renderMenuItems(filterItems(consultationItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Viste Personali (Guard) */}
|
||||
{filterItems(personalItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>I Miei Turni</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{renderMenuItems(filterItems(personalItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Anagrafica */}
|
||||
{filterItems(registryItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Anagrafica</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{renderMenuItems(filterItems(registryItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Report */}
|
||||
{filterItems(reportingItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Reporting</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{renderMenuItems(filterItems(reportingItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
|
||||
{/* Sistema */}
|
||||
{filterItems(systemItems).length > 0 && (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel>Sistema</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
{renderMenuItems(filterItems(systemItems))}
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
)}
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter className="p-4 border-t space-y-4">
|
||||
|
||||
273
client/src/pages/planning-view-fixed-agent.tsx
Normal file
273
client/src/pages/planning-view-fixed-agent.tsx
Normal file
@ -0,0 +1,273 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, Shield, Car, MapPin, Clock, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { format, addDays, startOfWeek } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
|
||||
type Location = "roccapiemonte" | "milano" | "roma";
|
||||
|
||||
export default function PlanningViewFixedAgent() {
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
|
||||
const [weekStart, setWeekStart] = useState<string>(
|
||||
format(startOfWeek(new Date(), { weekStartsOn: 1 }), "yyyy-MM-dd")
|
||||
);
|
||||
|
||||
const locationLabels: Record<Location, string> = {
|
||||
roccapiemonte: "Roccapiemonte",
|
||||
milano: "Milano",
|
||||
roma: "Roma",
|
||||
};
|
||||
|
||||
// Query guardie per location
|
||||
const { data: guards } = useQuery<any[]>({
|
||||
queryKey: ["/api/guards", selectedLocation],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/guards");
|
||||
if (!response.ok) throw new Error("Failed to fetch guards");
|
||||
const allGuards = await response.json();
|
||||
return allGuards.filter((g: any) => g.location === selectedLocation && g.isActive);
|
||||
},
|
||||
});
|
||||
|
||||
// Query planning agente fisso
|
||||
const { data: planningData, isLoading } = useQuery<{
|
||||
guard: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
badgeNumber: string;
|
||||
location: string;
|
||||
};
|
||||
weekStart: string;
|
||||
assignments: Array<{
|
||||
id: string;
|
||||
siteId: string;
|
||||
siteName: string;
|
||||
siteAddress: string;
|
||||
startTime: Date;
|
||||
endTime: Date;
|
||||
isArmedOnDuty: boolean;
|
||||
hasVehicle: boolean;
|
||||
vehicleId: string | null;
|
||||
location: string;
|
||||
}>;
|
||||
}>({
|
||||
queryKey: ["/api/planning/fixed-agent", selectedGuardId, weekStart],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({
|
||||
guardId: selectedGuardId,
|
||||
weekStart,
|
||||
});
|
||||
const response = await fetch(`/api/planning/fixed-agent?${params.toString()}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch planning");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedGuardId,
|
||||
});
|
||||
|
||||
const handlePreviousWeek = () => {
|
||||
const prevWeek = new Date(weekStart);
|
||||
prevWeek.setDate(prevWeek.getDate() - 7);
|
||||
setWeekStart(format(prevWeek, "yyyy-MM-dd"));
|
||||
};
|
||||
|
||||
const handleNextWeek = () => {
|
||||
const nextWeek = new Date(weekStart);
|
||||
nextWeek.setDate(nextWeek.getDate() + 7);
|
||||
setWeekStart(format(nextWeek, "yyyy-MM-dd"));
|
||||
};
|
||||
|
||||
// Raggruppa assignments per giorno
|
||||
const assignmentsByDay: Record<string, typeof planningData.assignments> = {};
|
||||
if (planningData) {
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const dayDate = format(addDays(new Date(weekStart), i), "yyyy-MM-dd");
|
||||
assignmentsByDay[dayDate] = planningData.assignments.filter((a) => {
|
||||
const assignmentDate = format(new Date(a.startTime), "yyyy-MM-dd");
|
||||
return assignmentDate === dayDate;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Planning Agente Fisso - Consultazione</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Visualizza i turni fissi pianificati per un agente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtri */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filtri</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Guardia</label>
|
||||
<Select value={selectedGuardId} onValueChange={setSelectedGuardId}>
|
||||
<SelectTrigger data-testid="select-guard">
|
||||
<SelectValue placeholder="Seleziona guardia" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{guards?.map((guard) => (
|
||||
<SelectItem key={guard.id} value={guard.id}>
|
||||
{guard.firstName} {guard.lastName} - #{guard.badgeNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigazione settimana */}
|
||||
{selectedGuardId && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<Button variant="outline" size="sm" onClick={handlePreviousWeek} data-testid="button-prev-week">
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Settimana Precedente
|
||||
</Button>
|
||||
<div className="text-sm font-medium">
|
||||
{format(new Date(weekStart), "dd MMM", { locale: it })} -{" "}
|
||||
{format(addDays(new Date(weekStart), 6), "dd MMM yyyy", { locale: it })}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleNextWeek} data-testid="button-next-week">
|
||||
Settimana Successiva
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Planning Data */}
|
||||
{selectedGuardId && planningData && (
|
||||
<>
|
||||
{/* Info Guardia */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>
|
||||
{planningData.guard.firstName} {planningData.guard.lastName}
|
||||
</span>
|
||||
<Badge variant="outline">#{planningData.guard.badgeNumber}</Badge>
|
||||
<Badge variant="secondary">{locationLabels[planningData.guard.location as Location]}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Turni fissi pianificati per la settimana</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Griglia Settimanale */}
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((dayOffset) => {
|
||||
const dayDate = addDays(new Date(weekStart), dayOffset);
|
||||
const dayKey = format(dayDate, "yyyy-MM-dd");
|
||||
const dayAssignments = assignmentsByDay[dayKey] || [];
|
||||
|
||||
return (
|
||||
<Card key={dayKey}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{format(dayDate, "EEEE dd MMMM", { locale: it })}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dayAssignments.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{dayAssignments.map((assignment) => (
|
||||
<div
|
||||
key={assignment.id}
|
||||
className="p-4 border rounded-lg space-y-2"
|
||||
data-testid={`assignment-${assignment.id}`}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{assignment.siteName}</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-1 mt-1">
|
||||
<MapPin className="h-3 w-3" />
|
||||
{assignment.siteAddress}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{format(new Date(assignment.startTime), "HH:mm")} -{" "}
|
||||
{format(new Date(assignment.endTime), "HH:mm")}
|
||||
</Badge>
|
||||
|
||||
{assignment.isArmedOnDuty && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Armato
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
{assignment.hasVehicle && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Car className="h-3 w-3 mr-1" />
|
||||
Automezzo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">Nessun turno pianificato</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedGuardId && isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<p className="text-muted-foreground">Caricamento planning...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!selectedGuardId && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Calendar className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium">Seleziona una guardia</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Scegli una guardia per visualizzare i turni fissi pianificati
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
275
client/src/pages/planning-view-mobile-agent.tsx
Normal file
275
client/src/pages/planning-view-mobile-agent.tsx
Normal file
@ -0,0 +1,275 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Calendar, MapPin, Clock, Car, Navigation, ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { format, addDays, startOfWeek, endOfWeek } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type Location = "roccapiemonte" | "milano" | "roma";
|
||||
|
||||
export default function PlanningViewMobileAgent() {
|
||||
const [selectedLocation, setSelectedLocation] = useState<Location>("roccapiemonte");
|
||||
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
|
||||
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(
|
||||
startOfWeek(new Date(), { weekStartsOn: 1 })
|
||||
);
|
||||
|
||||
const locationLabels: Record<Location, string> = {
|
||||
roccapiemonte: "Roccapiemonte",
|
||||
milano: "Milano",
|
||||
roma: "Roma",
|
||||
};
|
||||
|
||||
// Query guardie per location
|
||||
const { data: guards } = useQuery<any[]>({
|
||||
queryKey: ["/api/guards", selectedLocation],
|
||||
queryFn: async () => {
|
||||
const response = await fetch("/api/guards");
|
||||
if (!response.ok) throw new Error("Failed to fetch guards");
|
||||
const allGuards = await response.json();
|
||||
return allGuards.filter((g: any) => g.location === selectedLocation && g.isActive && g.hasDriverLicense);
|
||||
},
|
||||
});
|
||||
|
||||
// Query planning agente mobile per settimana
|
||||
const { data: weekData, isLoading } = useQuery<any>({
|
||||
queryKey: ["/api/planning/mobile-agent", selectedGuardId, currentWeekStart],
|
||||
queryFn: async () => {
|
||||
const startDate = format(currentWeekStart, "yyyy-MM-dd");
|
||||
const endDate = format(endOfWeek(currentWeekStart, { weekStartsOn: 1 }), "yyyy-MM-dd");
|
||||
const params = new URLSearchParams({
|
||||
guardId: selectedGuardId,
|
||||
startDate,
|
||||
endDate,
|
||||
});
|
||||
const response = await fetch(`/api/planning/mobile-agent?${params.toString()}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch planning");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedGuardId,
|
||||
});
|
||||
|
||||
const handlePreviousWeek = () => {
|
||||
setCurrentWeekStart((prev) => addDays(prev, -7));
|
||||
};
|
||||
|
||||
const handleNextWeek = () => {
|
||||
setCurrentWeekStart((prev) => addDays(prev, 7));
|
||||
};
|
||||
|
||||
const handleToday = () => {
|
||||
setCurrentWeekStart(startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
};
|
||||
|
||||
// Generate 7 days for the week
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(currentWeekStart, i));
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Planning Agente Mobile - Consultazione</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Vista settimanale dei percorsi pattuglia pianificati per un agente
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filtri */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filtri</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Guardia</label>
|
||||
<Select value={selectedGuardId} onValueChange={setSelectedGuardId}>
|
||||
<SelectTrigger data-testid="select-guard">
|
||||
<SelectValue placeholder="Seleziona guardia" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{guards?.map((guard) => (
|
||||
<SelectItem key={guard.id} value={guard.id}>
|
||||
{guard.firstName} {guard.lastName} - #{guard.badgeNumber}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigazione settimana */}
|
||||
{selectedGuardId && (
|
||||
<div className="flex items-center justify-between gap-2 pt-4">
|
||||
<Button variant="outline" size="sm" onClick={handlePreviousWeek} data-testid="button-prev-week">
|
||||
<ChevronLeft className="h-4 w-4 mr-2" />
|
||||
Settimana Precedente
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleToday} data-testid="button-today">
|
||||
Oggi
|
||||
</Button>
|
||||
<div className="text-sm font-medium text-center">
|
||||
{format(currentWeekStart, "dd MMM", { locale: it })} -{" "}
|
||||
{format(endOfWeek(currentWeekStart, { weekStartsOn: 1 }), "dd MMM yyyy", { locale: it })}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleNextWeek} data-testid="button-next-week">
|
||||
Settimana Successiva
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Planning Data - Vista Settimanale */}
|
||||
{selectedGuardId && weekData && weekData.guard && (
|
||||
<>
|
||||
{/* Info Guardia */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>
|
||||
{weekData.guard.firstName} {weekData.guard.lastName}
|
||||
</span>
|
||||
<Badge variant="outline">#{weekData.guard.badgeNumber}</Badge>
|
||||
<Badge variant="secondary">{locationLabels[weekData.guard.location as Location]}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Percorsi pattuglia pianificati nella settimana</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
|
||||
{/* Griglia Settimanale */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
{weekDays.map((day) => {
|
||||
const dateStr = format(day, "yyyy-MM-dd");
|
||||
const dayData = weekData.days?.find((d: any) => d.date === dateStr);
|
||||
|
||||
return (
|
||||
<Card key={dateStr} data-testid={`day-card-${dateStr}`}>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center justify-between">
|
||||
<span className="flex items-center gap-2">
|
||||
<Calendar className="h-4 w-4" />
|
||||
{format(day, "EEEE dd/MM", { locale: it })}
|
||||
</span>
|
||||
{dayData?.route && (
|
||||
<Badge variant="secondary">
|
||||
<Navigation className="h-3 w-3 mr-1" />
|
||||
{dayData.stops?.length || 0} tappe
|
||||
</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{dayData?.route ? (
|
||||
<div className="space-y-3">
|
||||
{/* Orario e dettagli turno */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
{dayData.route.startTime} - {dayData.route.endTime}
|
||||
</Badge>
|
||||
|
||||
{dayData.route.hasVehicle && (
|
||||
<Badge variant="secondary">
|
||||
<Car className="h-3 w-3 mr-1" />
|
||||
Automezzo
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Lista tappe */}
|
||||
{dayData.stops && dayData.stops.length > 0 && (
|
||||
<div className="space-y-2 pt-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase">
|
||||
Sequenza Tappe
|
||||
</div>
|
||||
{dayData.stops.map((stop: any) => (
|
||||
<div
|
||||
key={stop.id}
|
||||
className="flex items-start gap-2 text-sm"
|
||||
data-testid={`stop-${stop.sequenceOrder}`}
|
||||
>
|
||||
<div className="flex-shrink-0 w-5 h-5 rounded-full bg-primary text-primary-foreground flex items-center justify-center text-xs font-bold">
|
||||
{stop.sequenceOrder}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium truncate">{stop.siteName}</div>
|
||||
<div className="text-xs text-muted-foreground flex items-center gap-1">
|
||||
<MapPin className="h-3 w-3 flex-shrink-0" />
|
||||
<span className="truncate">{stop.siteAddress}</span>
|
||||
</div>
|
||||
{stop.estimatedArrivalTime && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Arrivo: {stop.estimatedArrivalTime}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{dayData.route.notes && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<strong>Note:</strong> {dayData.route.notes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<Navigation className="h-8 w-8 mx-auto text-muted-foreground mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Nessun percorso pianificato</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{selectedGuardId && isLoading && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<p className="text-muted-foreground">Caricamento planning...</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!selectedGuardId && (
|
||||
<Card>
|
||||
<CardContent className="py-16 text-center">
|
||||
<Navigation className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
|
||||
<p className="text-lg font-medium">Seleziona una guardia</p>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Scegli una guardia per visualizzare i percorsi pattuglia pianificati
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -42,6 +42,25 @@ export default function Shifts() {
|
||||
queryKey: ["/api/guards"],
|
||||
});
|
||||
|
||||
// Query guardie con controllo vincolo esclusività mobile (per assegnazione turni)
|
||||
const { data: guardsForShift } = useQuery<Array<GuardWithCertifications & { isBookedMobile: boolean }>>({
|
||||
queryKey: ["/api/guards/for-shift", selectedShift?.startTime, selectedShift?.site.location],
|
||||
queryFn: async () => {
|
||||
if (!selectedShift) return [];
|
||||
|
||||
const shiftDate = format(new Date(selectedShift.startTime), "yyyy-MM-dd");
|
||||
const params = new URLSearchParams({
|
||||
date: shiftDate,
|
||||
location: selectedShift.site.location,
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/guards/for-shift?${params.toString()}`);
|
||||
if (!response.ok) throw new Error("Failed to fetch guards for shift");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedShift && isAssignDialogOpen,
|
||||
});
|
||||
|
||||
// Filter data by location
|
||||
const filteredShifts = selectedLocation === "all"
|
||||
? shifts
|
||||
@ -563,11 +582,12 @@ export default function Shifts() {
|
||||
{/* Guards List */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Guardie Disponibili</h3>
|
||||
{guards && guards.length > 0 ? (
|
||||
{guardsForShift && guardsForShift.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{filteredGuards?.map((guard) => {
|
||||
{guardsForShift?.map((guard) => {
|
||||
const assigned = isGuardAssigned(guard.id);
|
||||
const canAssign = canGuardBeAssigned(guard);
|
||||
const bookedOnMobile = guard.isBookedMobile;
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -578,11 +598,16 @@ export default function Shifts() {
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium">
|
||||
{guard.user?.firstName} {guard.user?.lastName}
|
||||
{guard.firstName} {guard.lastName}
|
||||
</p>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
#{guard.badgeNumber}
|
||||
</span>
|
||||
{bookedOnMobile && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Su pattuglia mobile
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{guard.isArmed && (
|
||||
@ -615,10 +640,10 @@ export default function Shifts() {
|
||||
size="sm"
|
||||
variant={assigned ? "secondary" : "default"}
|
||||
onClick={() => handleAssignGuard(guard.id)}
|
||||
disabled={assigned || !canAssign}
|
||||
disabled={assigned || !canAssign || bookedOnMobile}
|
||||
data-testid={`button-assign-guard-${guard.id}`}
|
||||
>
|
||||
{assigned ? "Assegnato" : canAssign ? "Assegna" : "Non idoneo"}
|
||||
{assigned ? "Assegnato" : bookedOnMobile ? "Su pattuglia" : canAssign ? "Assegna" : "Non idoneo"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
14
replit.md
14
replit.md
@ -142,6 +142,20 @@ To prevent timezone-related bugs, especially when assigning shifts, dates should
|
||||
- Backend endpoint: GET `/api/site-planning/:siteId` with date range filters
|
||||
- **Impact**: Complete end-to-end planning system supporting both coordinator and guard roles with database-backed route planning and operational equipment tracking
|
||||
|
||||
### Planning Consultation Pages & Sidebar Reorganization (October 23, 2025)
|
||||
- **Issue**: Coordinators needed separate consultation views to review planned shifts without mixing creation and consultation workflows. Sidebar was cluttered with deprecated planning pages.
|
||||
- **Solution**:
|
||||
- **New Consultation Pages**:
|
||||
- `planning-view-fixed-agent.tsx`: Weekly view of guard's fixed shifts showing orari, dotazioni (armato, automezzo), location, sito
|
||||
- `planning-view-mobile-agent.tsx`: Weekly grid view (7 days) of guard's patrol routes with site addresses, sequenced stops, and equipment
|
||||
- Backend endpoint `/api/planning/mobile-agent` updated to accept startDate/endDate range and return `{ guard, days[] }` structure
|
||||
- **Sidebar Reorganization**:
|
||||
- Removed deprecated routes: `planning`, `operational-planning`, `general-planning`, `service-planning`
|
||||
- Created logical groups: Dashboard, Planning-Creazione (Turni Fissi, Pattuglie Mobile), Planning-Consultazione (Planning Agente Fisso, Planning Agente Mobile, Planning Sito), I Miei Turni, Anagrafica, Reporting, Sistema
|
||||
- Role-based filtering maintained for all groups
|
||||
- **Routes Cleanup**: Removed deprecated planning page imports and routes from App.tsx
|
||||
- **Impact**: Clear separation between creation (shifts.tsx, planning-mobile.tsx) and consultation (planning-view-*) workflows. Coordinators can now efficiently review weekly assignments for guards and sites without navigating through creation interfaces.
|
||||
|
||||
## External Dependencies
|
||||
- **Replit Auth**: For OpenID Connect (OIDC) based authentication.
|
||||
- **Neon**: Managed PostgreSQL database service.
|
||||
|
||||
285
server/routes.ts
285
server/routes.ts
@ -332,7 +332,290 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Get vehicles available for a location
|
||||
// Get guards for shift assignment with mobile exclusivity check
|
||||
app.get("/api/guards/for-shift", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { date, location } = req.query;
|
||||
|
||||
if (!date || typeof date !== "string") {
|
||||
return res.status(400).json({ message: "Date parameter required (YYYY-MM-DD)" });
|
||||
}
|
||||
|
||||
if (!location || !["roccapiemonte", "milano", "roma"].includes(location as string)) {
|
||||
return res.status(400).json({ message: "Valid location parameter required" });
|
||||
}
|
||||
|
||||
// Valida formato data
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(date)) {
|
||||
return res.status(400).json({ message: "Invalid date format, use YYYY-MM-DD" });
|
||||
}
|
||||
|
||||
// Ottieni tutte le guardie per la location
|
||||
const allGuards = await db
|
||||
.select({
|
||||
id: guards.id,
|
||||
userId: guards.userId,
|
||||
firstName: guards.firstName,
|
||||
lastName: guards.lastName,
|
||||
badgeNumber: guards.badgeNumber,
|
||||
location: guards.location,
|
||||
hasDriverLicense: guards.hasDriverLicense,
|
||||
isActive: guards.isActive,
|
||||
createdAt: guards.createdAt,
|
||||
updatedAt: guards.updatedAt,
|
||||
})
|
||||
.from(guards)
|
||||
.where(
|
||||
and(
|
||||
eq(guards.location, location as any),
|
||||
eq(guards.isActive, true)
|
||||
)
|
||||
)
|
||||
.orderBy(guards.lastName, guards.firstName);
|
||||
|
||||
// Verifica quali guardie hanno patrol routes (mobile) per questa data
|
||||
const patrolRoutesForDate = await db
|
||||
.select({
|
||||
guardId: patrolRoutes.guardId,
|
||||
})
|
||||
.from(patrolRoutes)
|
||||
.where(
|
||||
and(
|
||||
eq(patrolRoutes.shiftDate, date),
|
||||
eq(patrolRoutes.location, location as any),
|
||||
ne(patrolRoutes.status, "cancelled")
|
||||
)
|
||||
);
|
||||
|
||||
const guardsOnMobile = new Set(patrolRoutesForDate.map(pr => pr.guardId));
|
||||
|
||||
// Aggiungi flag isBookedMobile per ogni guardia
|
||||
const guardsWithMobileFlag = allGuards.map(guard => ({
|
||||
...guard,
|
||||
isBookedMobile: guardsOnMobile.has(guard.id),
|
||||
}));
|
||||
|
||||
res.json(guardsWithMobileFlag);
|
||||
} catch (error) {
|
||||
console.error("Error fetching guards for shift:", error);
|
||||
res.status(500).json({ message: "Failed to fetch guards for shift" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= PLANNING CONSULTATION ROUTES =============
|
||||
|
||||
// GET /api/planning/fixed-agent - Vista consultazione planning per agente fisso
|
||||
app.get("/api/planning/fixed-agent", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { guardId, weekStart } = req.query;
|
||||
|
||||
if (!guardId || typeof guardId !== "string") {
|
||||
return res.status(400).json({ message: "GuardId parameter required" });
|
||||
}
|
||||
|
||||
if (!weekStart || typeof weekStart !== "string") {
|
||||
return res.status(400).json({ message: "WeekStart parameter required (YYYY-MM-DD)" });
|
||||
}
|
||||
|
||||
// Valida formato data
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(weekStart)) {
|
||||
return res.status(400).json({ message: "Invalid weekStart format, use YYYY-MM-DD" });
|
||||
}
|
||||
|
||||
// Calcola fine settimana
|
||||
const weekStartDate = new Date(weekStart);
|
||||
weekStartDate.setHours(0, 0, 0, 0);
|
||||
|
||||
const weekEndDate = new Date(weekStart);
|
||||
weekEndDate.setDate(weekEndDate.getDate() + 6);
|
||||
weekEndDate.setHours(23, 59, 59, 999);
|
||||
|
||||
// Ottieni info guardia
|
||||
const guard = await db
|
||||
.select({
|
||||
id: guards.id,
|
||||
firstName: guards.firstName,
|
||||
lastName: guards.lastName,
|
||||
badgeNumber: guards.badgeNumber,
|
||||
location: guards.location,
|
||||
})
|
||||
.from(guards)
|
||||
.where(eq(guards.id, guardId))
|
||||
.limit(1);
|
||||
|
||||
if (guard.length === 0) {
|
||||
return res.status(404).json({ message: "Guard not found" });
|
||||
}
|
||||
|
||||
// Ottieni tutti i turni fissi della guardia per la settimana
|
||||
const assignments = await db
|
||||
.select({
|
||||
id: shiftAssignments.id,
|
||||
shiftId: shifts.id,
|
||||
siteId: sites.id,
|
||||
siteName: sites.name,
|
||||
siteAddress: sites.address,
|
||||
startTime: shifts.startTime,
|
||||
endTime: shifts.endTime,
|
||||
isArmedOnDuty: shiftAssignments.isArmedOnDuty,
|
||||
assignedVehicleId: shiftAssignments.assignedVehicleId,
|
||||
location: sites.location,
|
||||
})
|
||||
.from(shiftAssignments)
|
||||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||
.innerJoin(sites, eq(shifts.siteId, sites.id))
|
||||
.where(
|
||||
and(
|
||||
eq(shiftAssignments.guardId, guardId),
|
||||
gte(shifts.startTime, weekStartDate),
|
||||
lte(shifts.startTime, weekEndDate)
|
||||
)
|
||||
)
|
||||
.orderBy(shifts.startTime);
|
||||
|
||||
res.json({
|
||||
guard: guard[0],
|
||||
weekStart,
|
||||
assignments: assignments.map(a => ({
|
||||
id: a.id,
|
||||
shiftId: a.shiftId,
|
||||
siteId: a.siteId,
|
||||
siteName: a.siteName,
|
||||
siteAddress: a.siteAddress,
|
||||
startTime: a.startTime,
|
||||
endTime: a.endTime,
|
||||
isArmedOnDuty: a.isArmedOnDuty || false,
|
||||
hasVehicle: !!a.assignedVehicleId,
|
||||
vehicleId: a.assignedVehicleId,
|
||||
location: a.location,
|
||||
})),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching fixed agent planning:", error);
|
||||
res.status(500).json({ message: "Failed to fetch fixed agent planning" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/planning/mobile-agent - Vista consultazione planning per agente mobile
|
||||
app.get("/api/planning/mobile-agent", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { guardId, startDate, endDate } = req.query;
|
||||
|
||||
if (!guardId || typeof guardId !== "string") {
|
||||
return res.status(400).json({ message: "GuardId parameter required" });
|
||||
}
|
||||
|
||||
if (!startDate || typeof startDate !== "string") {
|
||||
return res.status(400).json({ message: "StartDate parameter required (YYYY-MM-DD)" });
|
||||
}
|
||||
|
||||
if (!endDate || typeof endDate !== "string") {
|
||||
return res.status(400).json({ message: "EndDate parameter required (YYYY-MM-DD)" });
|
||||
}
|
||||
|
||||
// Valida formato data
|
||||
const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if (!dateRegex.test(startDate) || !dateRegex.test(endDate)) {
|
||||
return res.status(400).json({ message: "Invalid date format, use YYYY-MM-DD" });
|
||||
}
|
||||
|
||||
// Ottieni info guardia
|
||||
const guard = await db
|
||||
.select({
|
||||
id: guards.id,
|
||||
firstName: guards.firstName,
|
||||
lastName: guards.lastName,
|
||||
badgeNumber: guards.badgeNumber,
|
||||
location: guards.location,
|
||||
})
|
||||
.from(guards)
|
||||
.where(eq(guards.id, guardId))
|
||||
.limit(1);
|
||||
|
||||
if (guard.length === 0) {
|
||||
return res.status(404).json({ message: "Guard not found" });
|
||||
}
|
||||
|
||||
// Ottieni tutti i patrol routes per questa guardia nel range di date
|
||||
const routes = await db
|
||||
.select({
|
||||
id: patrolRoutes.id,
|
||||
shiftDate: patrolRoutes.shiftDate,
|
||||
startTime: patrolRoutes.startTime,
|
||||
endTime: patrolRoutes.endTime,
|
||||
location: patrolRoutes.location,
|
||||
status: patrolRoutes.status,
|
||||
notes: patrolRoutes.notes,
|
||||
assignedVehicleId: patrolRoutes.assignedVehicleId,
|
||||
})
|
||||
.from(patrolRoutes)
|
||||
.where(
|
||||
and(
|
||||
eq(patrolRoutes.guardId, guardId),
|
||||
gte(patrolRoutes.shiftDate, startDate),
|
||||
lte(patrolRoutes.shiftDate, endDate)
|
||||
)
|
||||
)
|
||||
.orderBy(patrolRoutes.shiftDate);
|
||||
|
||||
// Per ogni route, ottieni gli stops
|
||||
const days = await Promise.all(
|
||||
routes.map(async (route) => {
|
||||
const stops = await db
|
||||
.select({
|
||||
id: patrolRouteStops.id,
|
||||
siteId: sites.id,
|
||||
siteName: sites.name,
|
||||
siteAddress: sites.address,
|
||||
latitude: sites.latitude,
|
||||
longitude: sites.longitude,
|
||||
sequenceOrder: patrolRouteStops.sequenceOrder,
|
||||
estimatedArrivalTime: patrolRouteStops.estimatedArrivalTime,
|
||||
})
|
||||
.from(patrolRouteStops)
|
||||
.innerJoin(sites, eq(patrolRouteStops.siteId, sites.id))
|
||||
.where(eq(patrolRouteStops.patrolRouteId, route.id))
|
||||
.orderBy(patrolRouteStops.sequenceOrder);
|
||||
|
||||
return {
|
||||
date: route.shiftDate,
|
||||
route: {
|
||||
id: route.id,
|
||||
startTime: route.startTime,
|
||||
endTime: route.endTime,
|
||||
location: route.location,
|
||||
status: route.status,
|
||||
notes: route.notes,
|
||||
hasVehicle: !!route.assignedVehicleId,
|
||||
vehicleId: route.assignedVehicleId,
|
||||
},
|
||||
stops: stops.map(s => ({
|
||||
id: s.id,
|
||||
siteId: s.siteId,
|
||||
siteName: s.siteName,
|
||||
siteAddress: s.siteAddress,
|
||||
latitude: s.latitude,
|
||||
longitude: s.longitude,
|
||||
sequenceOrder: s.sequenceOrder,
|
||||
estimatedArrivalTime: s.estimatedArrivalTime,
|
||||
})),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
guard: guard[0],
|
||||
days,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching mobile agent planning:", error);
|
||||
res.status(500).json({ message: "Failed to fetch mobile agent planning" });
|
||||
}
|
||||
});
|
||||
|
||||
// GET vehicles available for a location
|
||||
app.get("/api/vehicles/available", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { location } = req.query;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user