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:
parent
c8825a9b4c
commit
a10b50e7e9
@ -30,6 +30,7 @@ import PlanningMobile from "@/pages/planning-mobile";
|
||||
import MyShiftsFixed from "@/pages/my-shifts-fixed";
|
||||
import MyShiftsMobile from "@/pages/my-shifts-mobile";
|
||||
import SitePlanningView from "@/pages/site-planning-view";
|
||||
import WeeklyGuards from "@/pages/weekly-guards";
|
||||
|
||||
function Router() {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
@ -57,6 +58,7 @@ function Router() {
|
||||
<Route path="/my-shifts-fixed" component={MyShiftsFixed} />
|
||||
<Route path="/my-shifts-mobile" component={MyShiftsMobile} />
|
||||
<Route path="/site-planning-view" component={SitePlanningView} />
|
||||
<Route path="/weekly-guards" component={WeeklyGuards} />
|
||||
<Route path="/reports" component={Reports} />
|
||||
<Route path="/notifications" component={Notifications} />
|
||||
<Route path="/users" component={Users} />
|
||||
|
||||
@ -82,6 +82,12 @@ const menuItems: MenuItem[] = [
|
||||
icon: ClipboardList,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
{
|
||||
title: "Guardie Settimanale",
|
||||
url: "/weekly-guards",
|
||||
icon: Users,
|
||||
roles: ["admin", "coordinator"],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
315
client/src/pages/weekly-guards.tsx
Normal file
315
client/src/pages/weekly-guards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user