Add weekly guard schedule overview page

Introduce a new page for viewing weekly guard schedules, displaying guard assignments, absences, and site information per day.

Replit-Commit-Author: Agent
Replit-Commit-Session-Id: e0b5b11c-5b75-4389-8ea9-5f3cd9332f88
Replit-Commit-Checkpoint-Type: intermediate_checkpoint
Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/e0b5b11c-5b75-4389-8ea9-5f3cd9332f88/EPTvOHB
This commit is contained in:
marco370 2025-10-29 08:25:05 +00:00
parent c8825a9b4c
commit a10b50e7e9
3 changed files with 323 additions and 0 deletions

View File

@ -30,6 +30,7 @@ import PlanningMobile from "@/pages/planning-mobile";
import MyShiftsFixed from "@/pages/my-shifts-fixed"; import MyShiftsFixed from "@/pages/my-shifts-fixed";
import MyShiftsMobile from "@/pages/my-shifts-mobile"; import MyShiftsMobile from "@/pages/my-shifts-mobile";
import SitePlanningView from "@/pages/site-planning-view"; import SitePlanningView from "@/pages/site-planning-view";
import WeeklyGuards from "@/pages/weekly-guards";
function Router() { function Router() {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
@ -57,6 +58,7 @@ function Router() {
<Route path="/my-shifts-fixed" component={MyShiftsFixed} /> <Route path="/my-shifts-fixed" component={MyShiftsFixed} />
<Route path="/my-shifts-mobile" component={MyShiftsMobile} /> <Route path="/my-shifts-mobile" component={MyShiftsMobile} />
<Route path="/site-planning-view" component={SitePlanningView} /> <Route path="/site-planning-view" component={SitePlanningView} />
<Route path="/weekly-guards" component={WeeklyGuards} />
<Route path="/reports" component={Reports} /> <Route path="/reports" component={Reports} />
<Route path="/notifications" component={Notifications} /> <Route path="/notifications" component={Notifications} />
<Route path="/users" component={Users} /> <Route path="/users" component={Users} />

View File

@ -82,6 +82,12 @@ const menuItems: MenuItem[] = [
icon: ClipboardList, icon: ClipboardList,
roles: ["admin", "coordinator"], roles: ["admin", "coordinator"],
}, },
{
title: "Guardie Settimanale",
url: "/weekly-guards",
icon: Users,
roles: ["admin", "coordinator"],
},
], ],
}, },
{ {

View File

@ -0,0 +1,315 @@
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 { Calendar as CalendarIcon, ChevronLeft, ChevronRight, Users } from "lucide-react";
import { format, parseISO, addDays, startOfWeek, addWeeks } from "date-fns";
import { it } from "date-fns/locale";
type AbsenceType = "sick_leave" | "vacation" | "personal_leave" | "injury";
interface GuardScheduleData {
guard: {
id: string;
firstName: string;
lastName: string;
badgeNumber: string;
};
fixedShifts: Array<{
assignmentId: string;
shiftId: string;
plannedStartTime: Date;
plannedEndTime: Date;
siteName: string;
siteId: string;
}>;
mobileShifts: Array<{
routeId: string;
shiftDate: string;
startTime: string;
endTime: string;
}>;
absences: Array<{
id: string;
type: AbsenceType;
startDate: string;
endDate: string;
}>;
}
interface WeeklyScheduleResponse {
weekStart: string;
weekEnd: string;
location: string;
guards: GuardScheduleData[];
}
const ABSENCE_LABELS: Record<AbsenceType, string> = {
sick_leave: "Malattia",
vacation: "Ferie",
personal_leave: "Permesso",
injury: "Infortunio",
};
export default function WeeklyGuards() {
const [selectedLocation, setSelectedLocation] = useState<string>("roccapiemonte");
const [currentWeekStart, setCurrentWeekStart] = useState<Date>(() =>
startOfWeek(new Date(), { weekStartsOn: 1 }) // Inizia lunedì
);
const { data: scheduleData, isLoading } = useQuery<WeeklyScheduleResponse>({
queryKey: ["/api/weekly-guards-schedule", selectedLocation, format(currentWeekStart, "yyyy-MM-dd")],
enabled: !!selectedLocation,
});
// Helper per ottenere i giorni della settimana
const getWeekDays = () => {
const days = [];
for (let i = 0; i < 7; i++) {
days.push(addDays(currentWeekStart, i));
}
return days;
};
const weekDays = getWeekDays();
// Helper per trovare l'attività di una guardia in un giorno specifico
const getDayActivity = (guardData: GuardScheduleData, date: Date) => {
const dateStr = format(date, "yyyy-MM-dd");
// Controlla assenze
const absence = guardData.absences.find(abs => {
const startDate = abs.startDate;
const endDate = abs.endDate;
return dateStr >= startDate && dateStr <= endDate;
});
if (absence) {
return {
type: "absence" as const,
label: ABSENCE_LABELS[absence.type],
data: absence,
};
}
// Controlla turni fissi
const fixedShift = guardData.fixedShifts.find(shift => {
const shiftDate = format(new Date(shift.plannedStartTime), "yyyy-MM-dd");
return shiftDate === dateStr;
});
if (fixedShift) {
const startTime = format(new Date(fixedShift.plannedStartTime), "HH:mm");
const endTime = format(new Date(fixedShift.plannedEndTime), "HH:mm");
return {
type: "fixed" as const,
label: `${fixedShift.siteName} ${startTime}-${endTime}`,
data: fixedShift,
};
}
// Controlla turni mobili
const mobileShift = guardData.mobileShifts.find(shift => shift.shiftDate === dateStr);
if (mobileShift) {
return {
type: "mobile" as const,
label: `Pattuglia ${mobileShift.startTime}-${mobileShift.endTime}`,
data: mobileShift,
};
}
return null;
};
const handlePreviousWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, -1));
};
const handleNextWeek = () => {
setCurrentWeekStart(prev => addWeeks(prev, 1));
};
const handleCellClick = (activity: ReturnType<typeof getDayActivity>) => {
if (!activity) return;
if (activity.type === "fixed") {
// TODO: Aprire dialog turno fisso
console.log("Open fixed shift dialog:", activity.data);
} else if (activity.type === "mobile") {
// TODO: Aprire dialog turno mobile
console.log("Open mobile shift dialog:", activity.data);
}
// Se absence, non fare nulla
};
return (
<div className="container mx-auto p-6 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold flex items-center gap-2">
<Users className="h-8 w-8 text-primary" />
Guardie Settimanale
</h1>
<p className="text-muted-foreground mt-1">
Vista riepilogativa delle assegnazioni settimanali per sede
</p>
</div>
</div>
{/* Filtri */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<CalendarIcon className="h-5 w-5" />
Filtri Visualizzazione
</CardTitle>
<CardDescription>
Seleziona sede e settimana per visualizzare le assegnazioni
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="text-sm font-medium mb-2 block">Sede</label>
<Select
value={selectedLocation}
onValueChange={setSelectedLocation}
data-testid="select-location"
>
<SelectTrigger>
<SelectValue placeholder="Seleziona sede" />
</SelectTrigger>
<SelectContent>
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
<SelectItem value="milano">Milano</SelectItem>
<SelectItem value="roma">Roma</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="icon"
onClick={handlePreviousWeek}
data-testid="button-previous-week"
>
<ChevronLeft className="h-4 w-4" />
</Button>
<div className="px-4 py-2 border rounded-md bg-muted min-w-[280px] text-center">
<span className="font-medium">
{format(currentWeekStart, "d MMM", { locale: it })} - {format(addDays(currentWeekStart, 6), "d MMM yyyy", { locale: it })}
</span>
</div>
<Button
variant="outline"
size="icon"
onClick={handleNextWeek}
data-testid="button-next-week"
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Griglia Settimanale */}
{isLoading ? (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">Caricamento...</p>
</CardContent>
</Card>
) : scheduleData && scheduleData.guards.length > 0 ? (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="border-b bg-muted/50">
<th className="sticky left-0 z-10 bg-muted/50 text-left p-3 font-medium min-w-[180px]">
Guardia
</th>
{weekDays.map((day, index) => (
<th key={index} className="text-center p-3 font-medium min-w-[200px]">
<div>{format(day, "EEE", { locale: it })}</div>
<div className="text-xs text-muted-foreground font-normal">
{format(day, "dd/MM")}
</div>
</th>
))}
</tr>
</thead>
<tbody>
{scheduleData.guards.map((guardData) => (
<tr
key={guardData.guard.id}
className="border-b hover:bg-muted/30"
data-testid={`row-guard-${guardData.guard.id}`}
>
<td className="sticky left-0 z-10 bg-background p-3 font-medium border-r">
<div className="flex flex-col">
<span className="text-sm">
{guardData.guard.lastName} {guardData.guard.firstName}
</span>
<span className="text-xs text-muted-foreground">
#{guardData.guard.badgeNumber}
</span>
</div>
</td>
{weekDays.map((day, dayIndex) => {
const activity = getDayActivity(guardData, day);
return (
<td
key={dayIndex}
className="p-2 text-center align-middle"
>
{activity ? (
activity.type === "absence" ? (
<div
className="text-xs px-2 py-1.5 rounded bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-300"
data-testid={`cell-absence-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</div>
) : (
<Button
variant="outline"
size="sm"
className="w-full h-auto text-xs px-2 py-1.5 whitespace-normal hover-elevate"
onClick={() => handleCellClick(activity)}
data-testid={`button-shift-${guardData.guard.id}-${dayIndex}`}
>
{activity.label}
</Button>
)
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
) : (
<Card>
<CardContent className="p-6">
<p className="text-center text-muted-foreground">
Nessuna guardia trovata per la sede selezionata
</p>
</CardContent>
</Card>
)}
</div>
);
}