Add functionality to assign and remove guards from shifts

Implement API endpoints and UI components for managing shift assignments, including guard certifications and a new assignment removal mutation.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: 99f0fce6-9386-489a-9632-1d81223cab44
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/99f0fce6-9386-489a-9632-1d81223cab44/9lvKVew
This commit is contained in:
marco370 2025-10-11 11:03:43 +00:00
parent cbae8222cf
commit b3d0441306
4 changed files with 261 additions and 11 deletions

View File

@ -18,6 +18,10 @@ externalPort = 80
localPort = 33035 localPort = 33035
externalPort = 3001 externalPort = 3001
[[ports]]
localPort = 38805
externalPort = 3002
[[ports]] [[ports]]
localPort = 41343 localPort = 41343
externalPort = 3000 externalPort = 3000

View File

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useQuery, useMutation } from "@tanstack/react-query"; import { useQuery, useMutation } from "@tanstack/react-query";
import { ShiftWithDetails, InsertShift, Site, Guard } from "@shared/schema"; import { ShiftWithDetails, InsertShift, Site, GuardWithCertifications } from "@shared/schema";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
@ -9,17 +9,20 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { insertShiftFormSchema } from "@shared/schema"; import { insertShiftFormSchema } from "@shared/schema";
import { Plus, Calendar, MapPin, Users, Clock } from "lucide-react"; import { Plus, Calendar, MapPin, Users, Clock, UserPlus, X, Shield, Car, Heart, Flame } from "lucide-react";
import { apiRequest, queryClient } from "@/lib/queryClient"; import { apiRequest, queryClient } from "@/lib/queryClient";
import { useToast } from "@/hooks/use-toast"; import { useToast } from "@/hooks/use-toast";
import { StatusBadge } from "@/components/status-badge"; import { StatusBadge } from "@/components/status-badge";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { format } from "date-fns"; import { format } from "date-fns";
import { it } from "date-fns/locale"; import { it } from "date-fns/locale";
import { Badge } from "@/components/ui/badge";
export default function Shifts() { export default function Shifts() {
const { toast } = useToast(); const { toast } = useToast();
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedShift, setSelectedShift] = useState<ShiftWithDetails | null>(null);
const [isAssignDialogOpen, setIsAssignDialogOpen] = useState(false);
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({ const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
queryKey: ["/api/shifts"], queryKey: ["/api/shifts"],
@ -29,6 +32,10 @@ export default function Shifts() {
queryKey: ["/api/sites"], queryKey: ["/api/sites"],
}); });
const { data: guards } = useQuery<GuardWithCertifications[]>({
queryKey: ["/api/guards"],
});
const form = useForm({ const form = useForm({
resolver: zodResolver(insertShiftFormSchema), resolver: zodResolver(insertShiftFormSchema),
defaultValues: { defaultValues: {
@ -65,6 +72,89 @@ export default function Shifts() {
createMutation.mutate(data); createMutation.mutate(data);
}; };
const assignGuardMutation = useMutation({
mutationFn: async ({ shiftId, guardId }: { shiftId: string; guardId: string }) => {
return await apiRequest("POST", "/api/shift-assignments", { shiftId, guardId });
},
onSuccess: async () => {
// Invalidate and wait for refetch to complete
await queryClient.refetchQueries({ queryKey: ["/api/shifts"] });
// Now get the fresh data
const updatedShifts = queryClient.getQueryData<ShiftWithDetails[]>(["/api/shifts"]);
if (selectedShift && updatedShifts) {
const updatedShift = updatedShifts.find(s => s.id === selectedShift.id);
if (updatedShift) {
setSelectedShift(updatedShift);
}
}
toast({
title: "Guardia assegnata",
description: "La guardia è stata assegnata al turno con successo",
});
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const removeAssignmentMutation = useMutation({
mutationFn: async (assignmentId: string) => {
return await apiRequest("DELETE", `/api/shift-assignments/${assignmentId}`);
},
onSuccess: async () => {
// Invalidate and wait for refetch to complete
await queryClient.refetchQueries({ queryKey: ["/api/shifts"] });
// Now get the fresh data
const updatedShifts = queryClient.getQueryData<ShiftWithDetails[]>(["/api/shifts"]);
if (selectedShift && updatedShifts) {
const updatedShift = updatedShifts.find(s => s.id === selectedShift.id);
if (updatedShift) {
setSelectedShift(updatedShift);
}
}
toast({
title: "Assegnazione rimossa",
description: "La guardia è stata rimossa dal turno",
});
},
onError: (error) => {
toast({
title: "Errore",
description: error.message,
variant: "destructive",
});
},
});
const handleAssignGuard = (guardId: string) => {
if (selectedShift) {
assignGuardMutation.mutate({ shiftId: selectedShift.id, guardId });
}
};
const handleRemoveAssignment = (assignmentId: string) => {
removeAssignmentMutation.mutate(assignmentId);
};
const isGuardAssigned = (guardId: string) => {
return selectedShift?.assignments.some(a => a.guardId === guardId) || false;
};
const canGuardBeAssigned = (guard: GuardWithCertifications) => {
if (!selectedShift) return false;
const site = sites?.find(s => s.id === selectedShift.siteId);
if (!site) return true;
if (site.requiresArmed && !guard.isArmed) return false;
if (site.requiresDriverLicense && !guard.hasDriverLicense) return false;
return true;
};
const getStatusLabel = (status: string) => { const getStatusLabel = (status: string) => {
const labels: Record<string, string> = { const labels: Record<string, string> = {
planned: "Pianificato", planned: "Pianificato",
@ -231,6 +321,7 @@ export default function Shifts() {
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
<span> <span>
@ -239,14 +330,41 @@ export default function Shifts() {
: "Nessuna guardia assegnata"} : "Nessuna guardia assegnata"}
</span> </span>
</div> </div>
{shift.status === "planned" && (
<Button
size="sm"
variant="outline"
onClick={() => {
setSelectedShift(shift);
setIsAssignDialogOpen(true);
}}
data-testid={`button-assign-guards-${shift.id}`}
>
<UserPlus className="h-4 w-4 mr-2" />
Assegna Guardie
</Button>
)}
</div>
{shift.assignments.length > 0 && ( {shift.assignments.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2"> <div className="mt-3 flex flex-wrap gap-2">
{shift.assignments.map((assignment) => ( {shift.assignments.map((assignment) => (
<div <div
key={assignment.id} key={assignment.id}
className="text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded-md" className="flex items-center gap-1 text-xs bg-secondary text-secondary-foreground px-2 py-1 rounded-md"
data-testid={`assignment-${assignment.id}`}
> >
<span>
{assignment.guard.user?.firstName} {assignment.guard.user?.lastName} {assignment.guard.user?.firstName} {assignment.guard.user?.lastName}
</span>
{shift.status === "planned" && (
<button
onClick={() => handleRemoveAssignment(assignment.id)}
className="ml-1 hover:text-destructive"
data-testid={`button-remove-assignment-${assignment.id}`}
>
<X className="h-3 w-3" />
</button>
)}
</div> </div>
))} ))}
</div> </div>
@ -266,6 +384,118 @@ export default function Shifts() {
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Assignment Dialog */}
<Dialog open={isAssignDialogOpen} onOpenChange={setIsAssignDialogOpen}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Assegna Guardie al Turno</DialogTitle>
<DialogDescription>
{selectedShift && (
<>
{selectedShift.site.name} -{" "}
{format(new Date(selectedShift.startTime), "dd/MM/yyyy HH:mm", { locale: it })}
</>
)}
</DialogDescription>
</DialogHeader>
{selectedShift && (
<div className="space-y-4">
{/* Site Requirements */}
<div className="p-4 bg-muted rounded-lg">
<h3 className="font-medium mb-2">Requisiti Sito</h3>
<div className="flex flex-wrap gap-2">
{selectedShift.site.requiresArmed && (
<Badge variant="outline">
<Shield className="h-3 w-3 mr-1" />
Armato richiesto
</Badge>
)}
{selectedShift.site.requiresDriverLicense && (
<Badge variant="outline">
<Car className="h-3 w-3 mr-1" />
Patente richiesta
</Badge>
)}
<Badge variant="outline">
Min. {selectedShift.site.minGuards} guardie
</Badge>
</div>
</div>
{/* Guards List */}
<div className="space-y-2">
<h3 className="font-medium">Guardie Disponibili</h3>
{guards && guards.length > 0 ? (
<div className="space-y-2">
{guards.map((guard) => {
const assigned = isGuardAssigned(guard.id);
const canAssign = canGuardBeAssigned(guard);
return (
<div
key={guard.id}
className="flex items-center justify-between p-3 border rounded-lg"
data-testid={`guard-option-${guard.id}`}
>
<div className="flex-1">
<div className="flex items-center gap-2">
<p className="font-medium">
{guard.user?.firstName} {guard.user?.lastName}
</p>
<span className="text-xs text-muted-foreground font-mono">
#{guard.badgeNumber}
</span>
</div>
<div className="flex gap-1 mt-1">
{guard.isArmed && (
<Badge variant="secondary" className="text-xs">
<Shield className="h-3 w-3 mr-1" />
Armato
</Badge>
)}
{guard.hasDriverLicense && (
<Badge variant="secondary" className="text-xs">
<Car className="h-3 w-3 mr-1" />
Patente
</Badge>
)}
{guard.hasFirstAid && (
<Badge variant="secondary" className="text-xs">
<Heart className="h-3 w-3 mr-1" />
Primo Soccorso
</Badge>
)}
{guard.hasFireSafety && (
<Badge variant="secondary" className="text-xs">
<Flame className="h-3 w-3 mr-1" />
Antincendio
</Badge>
)}
</div>
</div>
<Button
size="sm"
variant={assigned ? "secondary" : "default"}
onClick={() => handleAssignGuard(guard.id)}
disabled={assigned || !canAssign}
data-testid={`button-assign-guard-${guard.id}`}
>
{assigned ? "Assegnato" : canAssign ? "Assegna" : "Non idoneo"}
</Button>
</div>
);
})}
</div>
) : (
<p className="text-sm text-muted-foreground">Nessuna guardia disponibile</p>
)}
</div>
</div>
)}
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -247,6 +247,16 @@ export async function registerRoutes(app: Express): Promise<Server> {
} }
}); });
app.delete("/api/shift-assignments/:id", isAuthenticated, async (req, res) => {
try {
await storage.deleteShiftAssignment(req.params.id);
res.json({ success: true });
} catch (error) {
console.error("Error deleting shift assignment:", error);
res.status(500).json({ message: "Failed to delete shift assignment" });
}
});
// ============= NOTIFICATION ROUTES ============= // ============= NOTIFICATION ROUTES =============
app.get("/api/notifications", isAuthenticated, async (req: any, res) => { app.get("/api/notifications", isAuthenticated, async (req: any, res) => {
try { try {

View File

@ -207,6 +207,12 @@ export class DatabaseStorage implements IStorage {
return newAssignment; return newAssignment;
} }
async deleteShiftAssignment(id: string): Promise<void> {
await db
.delete(shiftAssignments)
.where(eq(shiftAssignments.id, id));
}
// Notification operations // Notification operations
async getNotificationsByUser(userId: string): Promise<Notification[]> { async getNotificationsByUser(userId: string): Promise<Notification[]> {
return await db return await db