Implements multi-location filtering on Dashboard and Shifts pages, adds meal voucher configuration options in Parameters, and introduces multi-location seeding in server/seed.ts. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 42d8028a-fa71-4ec2-938c-e43eedf7df01 Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/6d543d2c-20b9-4ea6-93fe-70fe9b1d9f80/42d8028a-fa71-4ec2-938c-e43eedf7df01/IdDfihe
235 lines
8.8 KiB
TypeScript
235 lines
8.8 KiB
TypeScript
import { useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { useAuth } from "@/hooks/useAuth";
|
|
import { KPICard } from "@/components/kpi-card";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { StatusBadge } from "@/components/status-badge";
|
|
import { Users, Calendar, MapPin, AlertTriangle, Clock, CheckCircle, Building2 } from "lucide-react";
|
|
import { ShiftWithDetails, GuardWithCertifications, Site } from "@shared/schema";
|
|
import { formatDistanceToNow, format } from "date-fns";
|
|
import { it } from "date-fns/locale";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
|
export default function Dashboard() {
|
|
const { user } = useAuth();
|
|
const [selectedLocation, setSelectedLocation] = useState<string>("all");
|
|
|
|
const { data: shifts, isLoading: shiftsLoading } = useQuery<ShiftWithDetails[]>({
|
|
queryKey: ["/api/shifts/active"],
|
|
});
|
|
|
|
const { data: guards, isLoading: guardsLoading } = useQuery<GuardWithCertifications[]>({
|
|
queryKey: ["/api/guards"],
|
|
});
|
|
|
|
const { data: sites, isLoading: sitesLoading } = useQuery<Site[]>({
|
|
queryKey: ["/api/sites"],
|
|
});
|
|
|
|
// Filter data by location
|
|
const filteredGuards = selectedLocation === "all"
|
|
? guards
|
|
: guards?.filter(g => g.location === selectedLocation);
|
|
|
|
const filteredSites = selectedLocation === "all"
|
|
? sites
|
|
: sites?.filter(s => s.location === selectedLocation);
|
|
|
|
const filteredShifts = selectedLocation === "all"
|
|
? shifts
|
|
: shifts?.filter(s => {
|
|
const site = sites?.find(site => site.id === s.siteId);
|
|
return site?.location === selectedLocation;
|
|
});
|
|
|
|
// Calculate KPIs
|
|
const activeShifts = filteredShifts?.filter(s => s.status === "active").length || 0;
|
|
const totalGuards = filteredGuards?.length || 0;
|
|
const activeSites = filteredSites?.filter(s => s.isActive).length || 0;
|
|
|
|
// Expiring certifications (next 30 days)
|
|
const expiringCerts = filteredGuards?.flatMap(g =>
|
|
g.certifications.filter(c => c.status === "expiring_soon")
|
|
).length || 0;
|
|
|
|
const isLoading = shiftsLoading || guardsLoading || sitesLoading;
|
|
|
|
const locationLabels: Record<string, string> = {
|
|
all: "Tutte le Sedi",
|
|
roccapiemonte: "Roccapiemonte",
|
|
milano: "Milano",
|
|
roma: "Roma"
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-start justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-semibold mb-2">Dashboard Operativa</h1>
|
|
<p className="text-muted-foreground">
|
|
Benvenuto, {user?.firstName} {user?.lastName}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Building2 className="h-5 w-5 text-muted-foreground" />
|
|
<Select value={selectedLocation} onValueChange={setSelectedLocation}>
|
|
<SelectTrigger className="w-[200px]" data-testid="select-location">
|
|
<SelectValue placeholder="Seleziona sede" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="all">Tutte le Sedi</SelectItem>
|
|
<SelectItem value="roccapiemonte">Roccapiemonte</SelectItem>
|
|
<SelectItem value="milano">Milano</SelectItem>
|
|
<SelectItem value="roma">Roma</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
</div>
|
|
|
|
{/* KPI Cards */}
|
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
|
{isLoading ? (
|
|
<>
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
<Skeleton className="h-32" />
|
|
</>
|
|
) : (
|
|
<>
|
|
<KPICard
|
|
title="Turni Attivi"
|
|
value={activeShifts}
|
|
icon={Calendar}
|
|
data-testid="kpi-active-shifts"
|
|
/>
|
|
<KPICard
|
|
title="Guardie Totali"
|
|
value={totalGuards}
|
|
icon={Users}
|
|
data-testid="kpi-total-guards"
|
|
/>
|
|
<KPICard
|
|
title="Siti Attivi"
|
|
value={activeSites}
|
|
icon={MapPin}
|
|
data-testid="kpi-active-sites"
|
|
/>
|
|
<KPICard
|
|
title="Certificazioni in Scadenza"
|
|
value={expiringCerts}
|
|
icon={AlertTriangle}
|
|
className={expiringCerts > 0 ? "border-[hsl(25,90%,55%)]" : ""}
|
|
data-testid="kpi-expiring-certs"
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
<div className="grid gap-6 md:grid-cols-2">
|
|
{/* Active Shifts */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Clock className="h-5 w-5" />
|
|
Turni in Corso
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Servizi attualmente attivi
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{shiftsLoading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-16" />
|
|
<Skeleton className="h-16" />
|
|
</div>
|
|
) : filteredShifts && filteredShifts.length > 0 ? (
|
|
<div className="space-y-3">
|
|
{filteredShifts.slice(0, 5).map((shift) => (
|
|
<div
|
|
key={shift.id}
|
|
className="flex items-center justify-between p-3 rounded-md border hover-elevate"
|
|
data-testid={`shift-${shift.id}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">{shift.site.name}</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{shift.assignments.length} guardie assegnate
|
|
</p>
|
|
</div>
|
|
<StatusBadge status={shift.status === "active" ? "active" : "pending"}>
|
|
{shift.status === "active" ? "Attivo" : "Pianificato"}
|
|
</StatusBadge>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-muted-foreground text-center py-8">
|
|
Nessun turno attivo al momento
|
|
</p>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Alerts & Notifications */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="h-5 w-5" />
|
|
Alert e Scadenze
|
|
</CardTitle>
|
|
<CardDescription>
|
|
Certificazioni in scadenza prossimi 30 giorni
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{guardsLoading ? (
|
|
<div className="space-y-3">
|
|
<Skeleton className="h-16" />
|
|
<Skeleton className="h-16" />
|
|
</div>
|
|
) : filteredGuards ? (
|
|
<div className="space-y-3">
|
|
{filteredGuards
|
|
.flatMap(guard =>
|
|
guard.certifications
|
|
.filter(c => c.status === "expiring_soon" || c.status === "expired")
|
|
.map(cert => ({ guard, cert }))
|
|
)
|
|
.slice(0, 5)
|
|
.map(({ guard, cert }) => (
|
|
<div
|
|
key={cert.id}
|
|
className="flex items-center justify-between p-3 rounded-md border border-[hsl(25,90%,55%)]/30 bg-[hsl(25,90%,55%)]/5"
|
|
data-testid={`alert-${cert.id}`}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="font-medium truncate">
|
|
{guard.user?.firstName} {guard.user?.lastName}
|
|
</p>
|
|
<p className="text-sm text-muted-foreground">
|
|
{cert.name} - Scade {format(new Date(cert.expiryDate), "dd/MM/yyyy")}
|
|
</p>
|
|
</div>
|
|
<StatusBadge status={cert.status === "expired" ? "emergency" : "late"}>
|
|
{cert.status === "expired" ? "Scaduto" : "In scadenza"}
|
|
</StatusBadge>
|
|
</div>
|
|
))}
|
|
{filteredGuards.flatMap(g => g.certifications.filter(c => c.status !== "valid")).length === 0 && (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground justify-center py-8">
|
|
<CheckCircle className="h-4 w-4 text-[hsl(140,60%,45%)]" />
|
|
Tutte le certificazioni sono valide
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|