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 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} />
|
||||||
|
|||||||
@ -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"],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
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