Add functionality to create shifts directly from the planning view
Introduces API endpoints and client-side logic for fetching guard availability and creating multi-day shifts from the general planning interface. Replit-Commit-Author: Agent Replit-Commit-Session-Id: e5565357-90e1-419f-b9a8-6ee8394636df Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e5565357-90e1-419f-b9a8-6ee8394636df/DrGaAl6
This commit is contained in:
parent
6f9e24a76e
commit
eb3e6c4aac
4
.replit
4
.replit
@ -31,6 +31,10 @@ externalPort = 3002
|
||||
localPort = 43267
|
||||
externalPort = 3003
|
||||
|
||||
[[ports]]
|
||||
localPort = 45679
|
||||
externalPort = 4200
|
||||
|
||||
[env]
|
||||
PORT = "5000"
|
||||
|
||||
|
||||
@ -1,12 +1,14 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||
import { format, startOfWeek, addWeeks } from "date-fns";
|
||||
import { it } from "date-fns/locale";
|
||||
import { useLocation } from "wouter";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ChevronLeft, ChevronRight, Calendar, MapPin, Users, AlertTriangle, Car, Edit, CheckCircle2, Plus } from "lucide-react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
@ -17,6 +19,9 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import type { GuardAvailability } from "@shared/schema";
|
||||
|
||||
interface GuardWithHours {
|
||||
guardId: string;
|
||||
@ -65,10 +70,15 @@ interface GeneralPlanningResponse {
|
||||
|
||||
export default function GeneralPlanning() {
|
||||
const [, navigate] = useLocation();
|
||||
const { toast } = useToast();
|
||||
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
||||
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||
const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null);
|
||||
|
||||
// Form state per creazione turno
|
||||
const [selectedGuardId, setSelectedGuardId] = useState<string>("");
|
||||
const [days, setDays] = useState<number>(1);
|
||||
|
||||
// Query per dati planning settimanale
|
||||
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
||||
queryKey: ["/api/general-planning", format(weekStart, "yyyy-MM-dd"), selectedLocation],
|
||||
@ -81,6 +91,61 @@ export default function GeneralPlanning() {
|
||||
},
|
||||
});
|
||||
|
||||
// Query per guardie disponibili (solo quando dialog è aperto)
|
||||
const { data: availableGuards, isLoading: isLoadingGuards } = useQuery<GuardAvailability[]>({
|
||||
queryKey: ["/api/guards/availability", format(weekStart, "yyyy-MM-dd"), selectedCell?.siteId, selectedLocation],
|
||||
queryFn: async () => {
|
||||
if (!selectedCell) return [];
|
||||
const response = await fetch(
|
||||
`/api/guards/availability?weekStart=${format(weekStart, "yyyy-MM-dd")}&siteId=${selectedCell.siteId}&location=${selectedLocation}`
|
||||
);
|
||||
if (!response.ok) throw new Error("Failed to fetch guards availability");
|
||||
return response.json();
|
||||
},
|
||||
enabled: !!selectedCell, // Query attiva solo se dialog è aperto
|
||||
});
|
||||
|
||||
// Mutation per creare turno multi-giorno
|
||||
const createShiftMutation = useMutation({
|
||||
mutationFn: async (data: { siteId: string; startDate: string; days: number; guardId: string }) => {
|
||||
return apiRequest("/api/general-planning/shifts", "POST", data);
|
||||
},
|
||||
onSuccess: () => {
|
||||
// Invalida cache planning generale
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/general-planning"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["/api/guards/availability"] });
|
||||
|
||||
toast({
|
||||
title: "Turno creato",
|
||||
description: "Il turno è stato creato con successo",
|
||||
});
|
||||
|
||||
// Reset form e chiudi dialog
|
||||
setSelectedGuardId("");
|
||||
setDays(1);
|
||||
setSelectedCell(null);
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "Errore",
|
||||
description: error.message || "Impossibile creare il turno",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Handler per submit form creazione turno
|
||||
const handleCreateShift = () => {
|
||||
if (!selectedCell || !selectedGuardId) return;
|
||||
|
||||
createShiftMutation.mutate({
|
||||
siteId: selectedCell.siteId,
|
||||
startDate: selectedCell.date,
|
||||
days,
|
||||
guardId: selectedGuardId,
|
||||
});
|
||||
};
|
||||
|
||||
// Navigazione settimana
|
||||
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
|
||||
const goToNextWeek = () => setWeekStart(addWeeks(weekStart, 1));
|
||||
@ -459,6 +524,90 @@ export default function GeneralPlanning() {
|
||||
<p>Nessun turno pianificato per questa data</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form creazione nuovo turno */}
|
||||
<div className="border-t pt-4 space-y-4">
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<Plus className="h-4 w-4" />
|
||||
Crea Nuovo Turno
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
{/* Select guardia disponibile */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="guard-select">Guardia Disponibile</Label>
|
||||
{isLoadingGuards ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGuardId}
|
||||
onValueChange={setSelectedGuardId}
|
||||
disabled={createShiftMutation.isPending}
|
||||
>
|
||||
<SelectTrigger id="guard-select" data-testid="select-guard">
|
||||
<SelectValue placeholder="Seleziona guardia..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableGuards && availableGuards.length > 0 ? (
|
||||
availableGuards.map((guard) => (
|
||||
<SelectItem key={guard.guardId} value={guard.guardId}>
|
||||
{guard.guardName} ({guard.badgeNumber}) - {guard.weeklyHoursRemaining}h disponibili
|
||||
</SelectItem>
|
||||
))
|
||||
) : (
|
||||
<SelectItem value="no-guards" disabled>
|
||||
Nessuna guardia disponibile
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
{availableGuards && availableGuards.length > 0 && selectedGuardId && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{(() => {
|
||||
const guard = availableGuards.find(g => g.guardId === selectedGuardId);
|
||||
return guard ? `Ore assegnate: ${guard.weeklyHoursAssigned}h / ${guard.weeklyHoursMax}h (rimangono ${guard.weeklyHoursRemaining}h)` : "";
|
||||
})()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input numero giorni */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="days-input">Numero Giorni Consecutivi</Label>
|
||||
<Input
|
||||
id="days-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={7}
|
||||
value={days}
|
||||
onChange={(e) => setDays(Math.max(1, Math.min(7, parseInt(e.target.value) || 1)))}
|
||||
disabled={createShiftMutation.isPending}
|
||||
data-testid="input-days"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Il turno verrà creato a partire da {selectedCell && format(new Date(selectedCell.date), "dd/MM/yyyy")} per {days} {days === 1 ? "giorno" : "giorni"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bottone crea turno */}
|
||||
<Button
|
||||
onClick={handleCreateShift}
|
||||
disabled={!selectedGuardId || createShiftMutation.isPending || (availableGuards && availableGuards.length === 0)}
|
||||
data-testid="button-create-shift"
|
||||
className="w-full"
|
||||
>
|
||||
{createShiftMutation.isPending ? (
|
||||
"Creazione in corso..."
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Crea Turno ({days} {days === 1 ? "giorno" : "giorni"})
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
130
server/routes.ts
130
server/routes.ts
@ -4,7 +4,7 @@ import { storage } from "./storage";
|
||||
import { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||
import { db } from "./db";
|
||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes } from "@shared/schema";
|
||||
import { guards, certifications, sites, shifts, shiftAssignments, users, insertShiftSchema, contractParameters, vehicles, serviceTypes, createMultiDayShiftSchema } from "@shared/schema";
|
||||
import { eq, and, gte, lte, desc, asc, ne, sql } from "drizzle-orm";
|
||||
import { differenceInDays, differenceInHours, differenceInMinutes, startOfWeek, endOfWeek, startOfMonth, endOfMonth, isWithinInterval, startOfDay, isSameDay, parseISO, format, isValid, addDays } from "date-fns";
|
||||
|
||||
@ -294,6 +294,35 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Get guards availability for general planning
|
||||
app.get("/api/guards/availability", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const { weekStart, siteId, location } = req.query;
|
||||
|
||||
if (!weekStart || !siteId || !location) {
|
||||
return res.status(400).json({
|
||||
message: "Missing required parameters: weekStart, siteId, location"
|
||||
});
|
||||
}
|
||||
|
||||
const weekStartDate = parseISO(weekStart as string);
|
||||
if (!isValid(weekStartDate)) {
|
||||
return res.status(400).json({ message: "Invalid weekStart date format" });
|
||||
}
|
||||
|
||||
const availability = await storage.getGuardsAvailability(
|
||||
weekStartDate,
|
||||
siteId as string,
|
||||
location as string
|
||||
);
|
||||
|
||||
res.json(availability);
|
||||
} catch (error) {
|
||||
console.error("Error fetching guards availability:", error);
|
||||
res.status(500).json({ message: "Failed to fetch guards availability" });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= VEHICLE ROUTES =============
|
||||
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
@ -1042,6 +1071,105 @@ export async function registerRoutes(app: Express): Promise<Server> {
|
||||
}
|
||||
});
|
||||
|
||||
// Create multi-day shift from general planning
|
||||
app.post("/api/general-planning/shifts", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
// Validate request body
|
||||
const validationResult = createMultiDayShiftSchema.safeParse(req.body);
|
||||
if (!validationResult.success) {
|
||||
return res.status(400).json({
|
||||
message: "Invalid request data",
|
||||
errors: validationResult.error.errors
|
||||
});
|
||||
}
|
||||
|
||||
const { siteId, startDate, days, guardId, shiftType } = validationResult.data;
|
||||
|
||||
// Get site to check contract and service details
|
||||
const site = await storage.getSite(siteId);
|
||||
if (!site) {
|
||||
return res.status(404).json({ message: "Site not found" });
|
||||
}
|
||||
|
||||
// Get guard to verify it exists
|
||||
const guard = await storage.getGuard(guardId);
|
||||
if (!guard) {
|
||||
return res.status(404).json({ message: "Guard not found" });
|
||||
}
|
||||
|
||||
// Pre-validate all dates are within contract period
|
||||
const startDateParsed = parseISO(startDate);
|
||||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||||
const shiftDate = addDays(startDateParsed, dayOffset);
|
||||
const shiftDateStr = format(shiftDate, "yyyy-MM-dd");
|
||||
|
||||
if (site.contractStartDate && site.contractEndDate) {
|
||||
const contractStart = new Date(site.contractStartDate);
|
||||
const contractEnd = new Date(site.contractEndDate);
|
||||
if (shiftDate < contractStart || shiftDate > contractEnd) {
|
||||
return res.status(400).json({
|
||||
message: `Cannot create shift for ${shiftDateStr}: outside contract period`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create shifts atomically in a transaction
|
||||
const createdShifts = await db.transaction(async (tx) => {
|
||||
const createdShiftsInTx = [];
|
||||
|
||||
for (let dayOffset = 0; dayOffset < days; dayOffset++) {
|
||||
const shiftDate = addDays(startDateParsed, dayOffset);
|
||||
|
||||
// Use site service schedule or default 24h
|
||||
const serviceStart = site.serviceStartTime || "00:00";
|
||||
const serviceEnd = site.serviceEndTime || "23:59";
|
||||
|
||||
const [startHour, startMin] = serviceStart.split(":").map(Number);
|
||||
const [endHour, endMin] = serviceEnd.split(":").map(Number);
|
||||
|
||||
const shiftStart = new Date(shiftDate);
|
||||
shiftStart.setHours(startHour, startMin, 0, 0);
|
||||
|
||||
const shiftEnd = new Date(shiftDate);
|
||||
shiftEnd.setHours(endHour, endMin, 0, 0);
|
||||
|
||||
// If service ends before it starts, it spans midnight (add 1 day to end)
|
||||
if (shiftEnd <= shiftStart) {
|
||||
shiftEnd.setDate(shiftEnd.getDate() + 1);
|
||||
}
|
||||
|
||||
// Create shift in transaction
|
||||
const [shift] = await tx.insert(shifts).values({
|
||||
siteId: site.id,
|
||||
startTime: shiftStart,
|
||||
endTime: shiftEnd,
|
||||
shiftType: shiftType || site.shiftType || "fixed_post",
|
||||
status: "planned",
|
||||
}).returning();
|
||||
|
||||
// Create shift assignment in transaction
|
||||
await tx.insert(shiftAssignments).values({
|
||||
shiftId: shift.id,
|
||||
guardId: guard.id,
|
||||
});
|
||||
|
||||
createdShiftsInTx.push(shift);
|
||||
}
|
||||
|
||||
return createdShiftsInTx;
|
||||
});
|
||||
|
||||
res.json({
|
||||
message: `Created ${createdShifts.length} shifts`,
|
||||
shifts: createdShifts
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error creating multi-day shifts:", error);
|
||||
res.status(500).json({ message: "Failed to create shifts", error: String(error) });
|
||||
}
|
||||
});
|
||||
|
||||
// ============= CERTIFICATION ROUTES =============
|
||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
|
||||
@ -54,9 +54,11 @@ import {
|
||||
type InsertServiceType,
|
||||
type CcnlSetting,
|
||||
type InsertCcnlSetting,
|
||||
type GuardAvailability,
|
||||
} from "@shared/schema";
|
||||
import { db } from "./db";
|
||||
import { eq, and, gte, lte, desc, or } from "drizzle-orm";
|
||||
import { eq, and, gte, lte, desc, or, sql as rawSql } from "drizzle-orm";
|
||||
import { addDays, differenceInHours, parseISO, formatISO } from "date-fns";
|
||||
|
||||
export interface IStorage {
|
||||
// User operations (Replit Auth required)
|
||||
@ -153,6 +155,9 @@ export interface IStorage {
|
||||
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
|
||||
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
|
||||
deleteCcnlSetting(key: string): Promise<void>;
|
||||
|
||||
// General Planning operations
|
||||
getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]>;
|
||||
}
|
||||
|
||||
export class DatabaseStorage implements IStorage {
|
||||
@ -181,7 +186,9 @@ export class DatabaseStorage implements IStorage {
|
||||
.update(users)
|
||||
.set({
|
||||
...(userData.email && { email: userData.email }),
|
||||
...(userData.name && { name: userData.name }),
|
||||
...(userData.firstName && { firstName: userData.firstName }),
|
||||
...(userData.lastName && { lastName: userData.lastName }),
|
||||
...(userData.profileImageUrl && { profileImageUrl: userData.profileImageUrl }),
|
||||
...(userData.role && { role: userData.role }),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@ -660,6 +667,87 @@ export class DatabaseStorage implements IStorage {
|
||||
async deleteCcnlSetting(key: string): Promise<void> {
|
||||
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
|
||||
}
|
||||
|
||||
// General Planning operations
|
||||
async getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]> {
|
||||
const weekEnd = addDays(weekStart, 6);
|
||||
|
||||
// Get max weekly hours from CCNL settings (default 45h)
|
||||
const maxHoursSetting = await this.getCcnlSetting('weeklyGuardHours');
|
||||
const maxWeeklyHours = maxHoursSetting ? Number(maxHoursSetting.value) : 45;
|
||||
|
||||
// Get site to check requirements
|
||||
const site = await this.getSite(siteId);
|
||||
if (!site) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get all guards from the same location
|
||||
const allGuards = await db
|
||||
.select()
|
||||
.from(guards)
|
||||
.where(eq(guards.location, location as any));
|
||||
|
||||
// Filter guards by site requirements
|
||||
const eligibleGuards = allGuards.filter(guard => {
|
||||
if (site.requiresArmed && !guard.isArmed) return false;
|
||||
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Calculate weekly hours for each guard
|
||||
const guardsWithHours: GuardAvailability[] = [];
|
||||
|
||||
for (const guard of eligibleGuards) {
|
||||
// Get all shift assignments for this guard in the week
|
||||
const assignments = await db
|
||||
.select({
|
||||
shiftId: shiftAssignments.shiftId,
|
||||
startTime: shifts.startTime,
|
||||
endTime: shifts.endTime,
|
||||
})
|
||||
.from(shiftAssignments)
|
||||
.innerJoin(shifts, eq(shiftAssignments.shiftId, shifts.id))
|
||||
.where(
|
||||
and(
|
||||
eq(shiftAssignments.guardId, guard.id),
|
||||
gte(shifts.startTime, weekStart),
|
||||
lte(shifts.startTime, weekEnd)
|
||||
)
|
||||
);
|
||||
|
||||
// Calculate total hours assigned
|
||||
let weeklyHoursAssigned = 0;
|
||||
for (const assignment of assignments) {
|
||||
const hours = differenceInHours(assignment.endTime, assignment.startTime);
|
||||
weeklyHoursAssigned += hours;
|
||||
}
|
||||
|
||||
const weeklyHoursRemaining = maxWeeklyHours - weeklyHoursAssigned;
|
||||
|
||||
// Only include guards with remaining hours
|
||||
if (weeklyHoursRemaining > 0) {
|
||||
const user = guard.userId ? await this.getUser(guard.userId) : undefined;
|
||||
const guardName = user
|
||||
? `${user.firstName || ''} ${user.lastName || ''}`.trim() || 'N/A'
|
||||
: 'N/A';
|
||||
|
||||
guardsWithHours.push({
|
||||
guardId: guard.id,
|
||||
guardName,
|
||||
badgeNumber: guard.badgeNumber,
|
||||
weeklyHoursRemaining,
|
||||
weeklyHoursAssigned,
|
||||
weeklyHoursMax: maxWeeklyHours,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by remaining hours (descending)
|
||||
guardsWithHours.sort((a, b) => b.weeklyHoursRemaining - a.weeklyHoursRemaining);
|
||||
|
||||
return guardsWithHours;
|
||||
}
|
||||
}
|
||||
|
||||
export const storage = new DatabaseStorage();
|
||||
|
||||
@ -837,3 +837,28 @@ export type AbsenceWithDetails = Absence & {
|
||||
shift: Shift;
|
||||
})[];
|
||||
};
|
||||
|
||||
// ============= DTOs FOR GENERAL PLANNING =============
|
||||
|
||||
// DTO per disponibilità guardia nella settimana
|
||||
export const guardAvailabilitySchema = z.object({
|
||||
guardId: z.string(),
|
||||
guardName: z.string(),
|
||||
badgeNumber: z.string(),
|
||||
weeklyHoursRemaining: z.number(),
|
||||
weeklyHoursAssigned: z.number(),
|
||||
weeklyHoursMax: z.number(),
|
||||
});
|
||||
|
||||
export type GuardAvailability = z.infer<typeof guardAvailabilitySchema>;
|
||||
|
||||
// DTO per creazione turno multi-giorno dal Planning Generale
|
||||
export const createMultiDayShiftSchema = z.object({
|
||||
siteId: z.string(),
|
||||
startDate: z.string(), // YYYY-MM-DD
|
||||
days: z.number().min(1).max(7),
|
||||
guardId: z.string(),
|
||||
shiftType: z.enum(["fixed_post", "patrol", "night_inspection", "quick_response"]).optional(),
|
||||
});
|
||||
|
||||
export type CreateMultiDayShiftRequest = z.infer<typeof createMultiDayShiftSchema>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user