- {filteredGuards?.map((guard) => {
+ {guardsForShift?.map((guard) => {
const assigned = isGuardAssigned(guard.id);
const canAssign = canGuardBeAssigned(guard);
+ const bookedOnMobile = guard.isBookedMobile;
return (
- {guard.user?.firstName} {guard.user?.lastName}
+ {guard.firstName} {guard.lastName}
#{guard.badgeNumber}
+ {bookedOnMobile && (
+
+ Su pattuglia mobile
+
+ )}
{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"}
);
diff --git a/replit.md b/replit.md
index 467b9da..e15f60b 100644
--- a/replit.md
+++ b/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.
diff --git a/server/routes.ts b/server/routes.ts
index e563a53..a1c28ef 100644
--- a/server/routes.ts
+++ b/server/routes.ts
@@ -331,8 +331,291 @@ export async function registerRoutes(app: Express): Promise
{
res.status(500).json({ message: "Failed to fetch guards availability" });
}
});
+
+ // 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 vehicles available for a location
+ // 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;