159 lines
6.8 KiB
JavaScript
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>
|
|
);
|
|
}
|