Boulangerie_Website/frontend/src/pages/UserStatisticsPage.jsx
Louis Mazin a393d9409f nice
2026-10-05 18:22:05 +02:00

159 lines
6.8 KiB
JavaScript

import { useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react';
import MainCard from '@/components/MainCard';
import LoadingCard from '@/components/LoadingCard';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { timeApi } from '@/api/time';
import { planningApi } from '@/api/planning';
import { useAuth } from '@/context/useAuth';
import { dateKey, formatMinutes, personLabel, inputClass } from '@/components/planning/utils';
import StatisticsPeriodSelector from '@/components/statistics/StatisticsPeriodSelector';
const StatCard = ({ icon: Icon, label, value, detail }) => (
<div className="rounded-xl border bg-card p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm text-muted-foreground">{label}</p>
<p className="mt-1 text-2xl font-semibold tabular-nums">{value}</p>
{detail && <p className="mt-1 text-xs text-muted-foreground">{detail}</p>}
</div>
<span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
<Icon className="size-4" />
</span>
</div>
</div>
);
const rangeLabel = (stats) => {
if (!stats) return '';
const start = new Date(`${stats.start}T12:00:00`);
const end = new Date(`${stats.end}T12:00:00`);
if (stats.period === 'day') {
return start.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
}
if (stats.period === 'week') {
const startText = start.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
const endText = end.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' });
return `Du ${startText} au ${endText}`;
}
if (stats.period === 'month') {
return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
}
return String(start.getFullYear());
};
export default function UserStatisticsPage() {
const { user } = useAuth();
const [people, setPeople] = useState([]);
const [selectedUserId, setSelectedUserId] = useState('');
const [period, setPeriod] = useState('month');
const [anchor, setAnchor] = useState(dateKey());
const [stats, setStats] = useState(null);
const [loadingPeople, setLoadingPeople] = useState(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [reload, setReload] = useState(0);
const statsPermission = user?.management_permissions?.view_statistics;
const allowedIds = useMemo(() => {
if (user?.is_admin || statsPermission?.all_users) return null;
return new Set((statsPermission?.user_ids || []).map(String));
}, [user, statsPermission]);
useEffect(() => {
const controller = new AbortController();
setLoadingPeople(true);
setError('');
planningApi.people(controller.signal)
.then((rows) => {
const visible = (Array.isArray(rows) ? rows : []).filter((person) => (
user?.is_admin || allowedIds === null || allowedIds.has(String(person.id))
));
setPeople(visible);
setSelectedUserId((current) => (
visible.some((person) => String(person.id) === String(current))
? String(current)
: String(visible[0]?.id || '')
));
})
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoadingPeople(false); });
return () => controller.abort();
}, [user, allowedIds]);
useEffect(() => {
if (!selectedUserId) {
setStats(null);
return undefined;
}
const controller = new AbortController();
setLoading(true);
setError('');
timeApi.getStats({ user_id: selectedUserId, period, anchor }, controller.signal)
.then((data) => { if (!controller.signal.aborted) setStats(data); })
.catch((e) => { if (!controller.signal.aborted) setError(e.message); })
.finally(() => { if (!controller.signal.aborted) setLoading(false); });
return () => controller.abort();
}, [selectedUserId, period, anchor, reload]);
if (loadingPeople) {
return <LoadingCard icon={BarChart3} title="Statistiques" message="Chargement des salariés…" />;
}
return (
<MainCard title="Statistiques" icon={BarChart3}>
<div className="min-h-0 flex-1 overflow-y-auto p-4 sm:p-6">
<div className="mx-auto max-w-6xl space-y-5">
<div className="flex flex-col gap-3 rounded-xl border bg-card p-4 lg:flex-row lg:items-end">
<label className="grid min-w-0 flex-1 gap-1.5 text-sm font-medium">
Salarié
<select className={inputClass} value={selectedUserId} onChange={(e) => setSelectedUserId(e.target.value)}>
{people.length ? people.map((person) => (
<option key={person.id} value={person.id}>{personLabel(person)}</option>
)) : <option value="">Aucun salarié autorisé</option>}
</select>
</label>
<StatisticsPeriodSelector
period={period}
anchor={anchor}
onPeriodChange={setPeriod}
onAnchorChange={setAnchor}
/>
<Button type="button" variant="outline" size="icon" disabled={loading} aria-label="Actualiser" onClick={() => setReload((value) => value + 1)}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />
</Button>
</div>
{error && <p role="alert" className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">{error}</p>}
{stats && (
<>
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<h2 className="text-lg font-semibold capitalize">{rangeLabel(stats)}</h2>
<p className="text-sm text-muted-foreground">{personLabel(stats.user)}</p>
</div>
{stats.incomplete_entries > 0 && (
<Badge variant="outline">{stats.incomplete_entries} pointage(s) incomplet(s) non compté(s)</Badge>
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<StatCard icon={Clock3} label="Travaillées / prévues" value={`${formatMinutes(stats.total_minutes)} / ${formatMinutes(stats.planned_minutes || 0)}`} />
<StatCard icon={CarFront} label="Aller-retours" value={stats.round_trips} />
<StatCard icon={Sun} label="Dimanches travaillés" value={stats.sundays_worked} />
<StatCard icon={CalendarDays} label="Heures le dimanche" value={formatMinutes(stats.sunday_minutes)} />
</div>
</>
)}
</div>
</div>
</MainCard>
);
}