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
|
localPort = 43267
|
||||||
externalPort = 3003
|
externalPort = 3003
|
||||||
|
|
||||||
|
[[ports]]
|
||||||
|
localPort = 45679
|
||||||
|
externalPort = 4200
|
||||||
|
|
||||||
[env]
|
[env]
|
||||||
PORT = "5000"
|
PORT = "5000"
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import { useState } from "react";
|
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 { format, startOfWeek, addWeeks } from "date-fns";
|
||||||
import { it } from "date-fns/locale";
|
import { it } from "date-fns/locale";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
@ -17,6 +19,9 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
import { queryClient, apiRequest } from "@/lib/queryClient";
|
||||||
|
import { useToast } from "@/hooks/use-toast";
|
||||||
|
import type { GuardAvailability } from "@shared/schema";
|
||||||
|
|
||||||
interface GuardWithHours {
|
interface GuardWithHours {
|
||||||
guardId: string;
|
guardId: string;
|
||||||
@ -65,10 +70,15 @@ interface GeneralPlanningResponse {
|
|||||||
|
|
||||||
export default function GeneralPlanning() {
|
export default function GeneralPlanning() {
|
||||||
const [, navigate] = useLocation();
|
const [, navigate] = useLocation();
|
||||||
|
const { toast } = useToast();
|
||||||
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
|
||||||
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
|
const [weekStart, setWeekStart] = useState<Date>(() => startOfWeek(new Date(), { weekStartsOn: 1 }));
|
||||||
const [selectedCell, setSelectedCell] = useState<{ siteId: string; siteName: string; date: string; data: SiteData } | null>(null);
|
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
|
// Query per dati planning settimanale
|
||||||
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
const { data: planningData, isLoading } = useQuery<GeneralPlanningResponse>({
|
||||||
queryKey: ["/api/general-planning", format(weekStart, "yyyy-MM-dd"), selectedLocation],
|
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
|
// Navigazione settimana
|
||||||
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
|
const goToPreviousWeek = () => setWeekStart(addWeeks(weekStart, -1));
|
||||||
const goToNextWeek = () => 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>
|
<p>Nessun turno pianificato per questa data</p>
|
||||||
</div>
|
</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>
|
</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 { setupAuth as setupReplitAuth, isAuthenticated as isAuthenticatedReplit } from "./replitAuth";
|
||||||
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
import { setupLocalAuth, isAuthenticated as isAuthenticatedLocal } from "./localAuth";
|
||||||
import { db } from "./db";
|
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 { 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";
|
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 =============
|
// ============= VEHICLE ROUTES =============
|
||||||
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
|
app.get("/api/vehicles", isAuthenticated, async (req, res) => {
|
||||||
try {
|
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 =============
|
// ============= CERTIFICATION ROUTES =============
|
||||||
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
app.post("/api/certifications", isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -54,9 +54,11 @@ import {
|
|||||||
type InsertServiceType,
|
type InsertServiceType,
|
||||||
type CcnlSetting,
|
type CcnlSetting,
|
||||||
type InsertCcnlSetting,
|
type InsertCcnlSetting,
|
||||||
|
type GuardAvailability,
|
||||||
} from "@shared/schema";
|
} from "@shared/schema";
|
||||||
import { db } from "./db";
|
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 {
|
export interface IStorage {
|
||||||
// User operations (Replit Auth required)
|
// User operations (Replit Auth required)
|
||||||
@ -153,6 +155,9 @@ export interface IStorage {
|
|||||||
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
|
getCcnlSetting(key: string): Promise<CcnlSetting | undefined>;
|
||||||
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
|
upsertCcnlSetting(setting: InsertCcnlSetting): Promise<CcnlSetting>;
|
||||||
deleteCcnlSetting(key: string): Promise<void>;
|
deleteCcnlSetting(key: string): Promise<void>;
|
||||||
|
|
||||||
|
// General Planning operations
|
||||||
|
getGuardsAvailability(weekStart: Date, siteId: string, location: string): Promise<GuardAvailability[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DatabaseStorage implements IStorage {
|
export class DatabaseStorage implements IStorage {
|
||||||
@ -181,7 +186,9 @@ export class DatabaseStorage implements IStorage {
|
|||||||
.update(users)
|
.update(users)
|
||||||
.set({
|
.set({
|
||||||
...(userData.email && { email: userData.email }),
|
...(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 }),
|
...(userData.role && { role: userData.role }),
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
})
|
})
|
||||||
@ -660,6 +667,87 @@ export class DatabaseStorage implements IStorage {
|
|||||||
async deleteCcnlSetting(key: string): Promise<void> {
|
async deleteCcnlSetting(key: string): Promise<void> {
|
||||||
await db.delete(ccnlSettings).where(eq(ccnlSettings.key, key));
|
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();
|
export const storage = new DatabaseStorage();
|
||||||
|
|||||||
@ -837,3 +837,28 @@ export type AbsenceWithDetails = Absence & {
|
|||||||
shift: Shift;
|
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