This commit is contained in:
Louis Mazin 2026-10-05 18:22:05 +02:00
parent e46e3f8ed0
commit a393d9409f
4 changed files with 188 additions and 66 deletions

View File

@ -282,8 +282,8 @@ class TimeStatsView(APIView):
def get(self, request): def get(self, request):
period = request.query_params.get('period', 'month') period = request.query_params.get('period', 'month')
if period not in ('day', 'month', 'year'): if period not in ('day', 'week', 'month', 'year'):
raise ValidationError({'period': 'Vue inconnue. Choisissez jour, mois ou année.'}) raise ValidationError({'period': 'Vue inconnue. Choisissez jour, semaine, mois ou année.'})
anchor_raw = request.query_params.get('anchor') or timezone.localdate().isoformat() anchor_raw = request.query_params.get('anchor') or timezone.localdate().isoformat()
try: try:
@ -307,6 +307,9 @@ class TimeStatsView(APIView):
if period == 'day': if period == 'day':
start = end = anchor start = end = anchor
elif period == 'week':
start = anchor - timedelta(days=anchor.weekday())
end = start + timedelta(days=6)
elif period == 'month': elif period == 'month':
start = anchor.replace(day=1) start = anchor.replace(day=1)
end = anchor.replace(day=monthrange(anchor.year, anchor.month)[1]) end = anchor.replace(day=monthrange(anchor.year, anchor.month)[1])

View File

@ -2,15 +2,9 @@ import { useEffect, useState } from 'react';
import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react'; import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { DatePicker } from '@/components/ResponsivePickers';
import { timeApi } from '@/api/time'; import { timeApi } from '@/api/time';
import { dateKey, formatMinutes } from '@/components/planning/utils'; import { dateKey, formatMinutes } from '@/components/planning/utils';
import StatisticsPeriodSelector from '@/components/statistics/StatisticsPeriodSelector';
const PERIODS = [
{ key: 'day', label: 'Jour' },
{ key: 'month', label: 'Mois' },
{ key: 'year', label: 'Année' },
];
const StatCard = ({ icon: Icon, label, value }) => ( const StatCard = ({ icon: Icon, label, value }) => (
<div className="rounded-xl border bg-background/50 p-4"> <div className="rounded-xl border bg-background/50 p-4">
@ -37,6 +31,12 @@ const rangeLabel = (stats) => {
year: 'numeric', year: 'numeric',
}); });
} }
if (stats.period === 'week') {
const end = new Date(`${stats.end}T12:00:00`);
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') { if (stats.period === 'month') {
return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
} }
@ -72,8 +72,6 @@ export default function PersonalStatistics() {
return () => controller.abort(); return () => controller.abort();
}, [period, anchor, reload]); }, [period, anchor, reload]);
const monthValue = anchor.slice(0, 7);
const yearValue = anchor.slice(0, 4);
return ( return (
<section aria-labelledby="personal-statistics-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5"> <section aria-labelledby="personal-statistics-title" className="rounded-xl border border-border bg-card p-4 text-card-foreground shadow-sm sm:p-5">
@ -94,29 +92,12 @@ export default function PersonalStatistics() {
</div> </div>
<div className="mt-4 flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 lg:flex-row lg:items-end"> <div className="mt-4 flex flex-col gap-3 rounded-lg border bg-muted/20 p-3 lg:flex-row lg:items-end">
<div className="grid gap-1.5 text-sm font-medium"> <StatisticsPeriodSelector
Période period={period}
<div className="flex flex-wrap gap-2"> anchor={anchor}
{PERIODS.map((item) => ( onPeriodChange={setPeriod}
<Button key={item.key} type="button" size="sm" variant={period === item.key ? 'default' : 'outline'} onClick={() => setPeriod(item.key)}> onAnchorChange={setAnchor}
{item.label} />
</Button>
))}
</div>
</div>
<label className="grid min-w-0 flex-1 gap-1.5 text-sm font-medium lg:max-w-60">
{period === 'day' ? 'Date' : period === 'month' ? 'Mois' : 'Année'}
{period === 'day' && (
<DatePicker value={anchor} onChange={setAnchor} />
)}
{period === 'month' && (
<DatePicker mode="month" value={monthValue} onChange={(value) => setAnchor(`${value}-01`)} />
)}
{period === 'year' && (
<DatePicker mode="year" min="2020" max="2100" value={yearValue} onChange={(value) => setAnchor(`${value}-01-01`)} />
)}
</label>
</div> </div>
{error && ( {error && (

View File

@ -0,0 +1,158 @@
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { DatePicker } from '@/components/ResponsivePickers';
import { Button } from '@/components/ui/button';
const PERIODS = [
{ key: 'day', label: 'Jour' },
{ key: 'week', label: 'Semaine' },
{ key: 'month', label: 'Mois' },
{ key: 'year', label: 'Année' },
];
const parseAnchor = (value) => {
const [year, month, day] = String(value || '').split('-').map(Number);
if (!year || !month || !day) return new Date();
return new Date(year, month - 1, day, 12, 0, 0, 0);
};
const dateKey = (date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const startOfWeek = (value) => {
const date = parseAnchor(value);
const weekday = (date.getDay() + 6) % 7;
date.setDate(date.getDate() - weekday);
return dateKey(date);
};
const normalizedAnchorForPeriod = (value, period) => {
const date = parseAnchor(value);
if (period === 'week') return startOfWeek(value);
if (period === 'month') {
date.setDate(1);
return dateKey(date);
}
if (period === 'year') {
date.setMonth(0, 1);
return dateKey(date);
}
return dateKey(date);
};
const shiftAnchor = (value, period, direction) => {
const date = parseAnchor(normalizedAnchorForPeriod(value, period));
if (period === 'day') {
date.setDate(date.getDate() + direction);
} else if (period === 'week') {
date.setDate(date.getDate() + (7 * direction));
} else if (period === 'month') {
date.setMonth(date.getMonth() + direction, 1);
} else {
date.setFullYear(date.getFullYear() + direction, 0, 1);
}
return dateKey(date);
};
export default function StatisticsPeriodSelector({ period, anchor, onPeriodChange, onAnchorChange, className = '' }) {
const monthValue = anchor.slice(0, 7);
const yearValue = anchor.slice(0, 4);
const changePeriod = (nextPeriod) => {
onPeriodChange(nextPeriod);
onAnchorChange(normalizedAnchorForPeriod(anchor, nextPeriod));
};
const move = (direction) => {
onAnchorChange(shiftAnchor(anchor, period, direction));
};
const picker = (() => {
if (period === 'month') {
return (
<DatePicker
mode="month"
value={monthValue}
onChange={(value) => onAnchorChange(`${value}-01`)}
/>
);
}
if (period === 'year') {
return (
<DatePicker
mode="year"
min="2020"
max="2100"
value={yearValue}
onChange={(value) => onAnchorChange(`${value}-01-01`)}
/>
);
}
return (
<DatePicker
value={period === 'week' ? startOfWeek(anchor) : anchor}
placeholder={period === 'week' ? 'Choisir une semaine' : 'Choisir une date'}
onChange={(value) => onAnchorChange(period === 'week' ? startOfWeek(value) : value)}
/>
);
})();
return (
<div className={`contents ${className}`}>
<div className="grid gap-1.5 text-sm font-medium">
Période
<div className="flex flex-wrap gap-2">
{PERIODS.map((item) => (
<Button
key={item.key}
type="button"
size="sm"
variant={period === item.key ? 'default' : 'outline'}
onClick={() => changePeriod(item.key)}
>
{item.label}
</Button>
))}
</div>
</div>
<div className="grid min-w-0 gap-1.5 text-sm font-medium lg:w-72">
{period === 'day' ? 'Date' : period === 'week' ? 'Semaine' : period === 'month' ? 'Mois' : 'Année'}
<div className="flex min-w-0 items-center gap-1.5">
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
aria-label="Période précédente"
title="Période précédente"
onClick={() => move(-1)}
>
<ChevronLeft className="size-4" />
</Button>
<div className="min-w-0 flex-1">{picker}</div>
<Button
type="button"
variant="outline"
size="icon"
className="size-9 shrink-0"
aria-label="Période suivante"
title="Période suivante"
onClick={() => move(1)}
>
<ChevronRight className="size-4" />
</Button>
</div>
</div>
</div>
);
}

View File

@ -2,19 +2,13 @@ import { useEffect, useMemo, useState } from 'react';
import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react'; import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react';
import MainCard from '@/components/MainCard'; import MainCard from '@/components/MainCard';
import LoadingCard from '@/components/LoadingCard'; import LoadingCard from '@/components/LoadingCard';
import { DatePicker } from '@/components/ResponsivePickers';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { timeApi } from '@/api/time'; import { timeApi } from '@/api/time';
import { planningApi } from '@/api/planning'; import { planningApi } from '@/api/planning';
import { useAuth } from '@/context/useAuth'; import { useAuth } from '@/context/useAuth';
import { dateKey, formatMinutes, personLabel, inputClass } from '@/components/planning/utils'; import { dateKey, formatMinutes, personLabel, inputClass } from '@/components/planning/utils';
import StatisticsPeriodSelector from '@/components/statistics/StatisticsPeriodSelector';
const PERIODS = [
{ key: 'day', label: 'Jour' },
{ key: 'month', label: 'Mois' },
{ key: 'year', label: 'Année' },
];
const StatCard = ({ icon: Icon, label, value, detail }) => ( const StatCard = ({ icon: Icon, label, value, detail }) => (
<div className="rounded-xl border bg-card p-4 shadow-sm"> <div className="rounded-xl border bg-card p-4 shadow-sm">
@ -38,6 +32,11 @@ const rangeLabel = (stats) => {
if (stats.period === 'day') { if (stats.period === 'day') {
return start.toLocaleDateString('fr-FR', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }); 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') { if (stats.period === 'month') {
return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' }); return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
} }
@ -102,8 +101,6 @@ export default function UserStatisticsPage() {
return <LoadingCard icon={BarChart3} title="Statistiques" message="Chargement des salariés…" />; return <LoadingCard icon={BarChart3} title="Statistiques" message="Chargement des salariés…" />;
} }
const monthValue = anchor.slice(0, 7);
const yearValue = anchor.slice(0, 4);
return ( return (
<MainCard title="Statistiques" icon={BarChart3}> <MainCard title="Statistiques" icon={BarChart3}>
@ -119,29 +116,12 @@ export default function UserStatisticsPage() {
</select> </select>
</label> </label>
<div className="grid gap-1.5 text-sm font-medium"> <StatisticsPeriodSelector
Période period={period}
<div className="flex flex-wrap gap-2"> anchor={anchor}
{PERIODS.map((item) => ( onPeriodChange={setPeriod}
<Button key={item.key} type="button" size="sm" variant={period === item.key ? 'default' : 'outline'} onClick={() => setPeriod(item.key)}> onAnchorChange={setAnchor}
{item.label} />
</Button>
))}
</div>
</div>
<label className="grid gap-1.5 text-sm font-medium lg:w-52">
{period === 'day' ? 'Date' : period === 'month' ? 'Mois' : 'Année'}
{period === 'day' && (
<DatePicker value={anchor} onChange={setAnchor} />
)}
{period === 'month' && (
<DatePicker mode="month" value={monthValue} onChange={(value) => setAnchor(`${value}-01`)} />
)}
{period === 'year' && (
<DatePicker mode="year" min="2020" max="2100" value={yearValue} onChange={(value) => setAnchor(`${value}-01-01`)} />
)}
</label>
<Button type="button" variant="outline" size="icon" disabled={loading} aria-label="Actualiser" onClick={() => setReload((value) => value + 1)}> <Button type="button" variant="outline" size="icon" disabled={loading} aria-label="Actualiser" onClick={() => setReload((value) => value + 1)}>
<RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={`size-4 ${loading ? 'animate-spin' : ''}`} />