nice
This commit is contained in:
parent
e46e3f8ed0
commit
a393d9409f
@ -282,8 +282,8 @@ class TimeStatsView(APIView):
|
||||
|
||||
def get(self, request):
|
||||
period = request.query_params.get('period', 'month')
|
||||
if period not in ('day', 'month', 'year'):
|
||||
raise ValidationError({'period': 'Vue inconnue. Choisissez jour, mois ou année.'})
|
||||
if period not in ('day', 'week', 'month', 'year'):
|
||||
raise ValidationError({'period': 'Vue inconnue. Choisissez jour, semaine, mois ou année.'})
|
||||
|
||||
anchor_raw = request.query_params.get('anchor') or timezone.localdate().isoformat()
|
||||
try:
|
||||
@ -307,6 +307,9 @@ class TimeStatsView(APIView):
|
||||
|
||||
if period == 'day':
|
||||
start = end = anchor
|
||||
elif period == 'week':
|
||||
start = anchor - timedelta(days=anchor.weekday())
|
||||
end = start + timedelta(days=6)
|
||||
elif period == 'month':
|
||||
start = anchor.replace(day=1)
|
||||
end = anchor.replace(day=monthrange(anchor.year, anchor.month)[1])
|
||||
|
||||
@ -2,15 +2,9 @@ import { useEffect, useState } from 'react';
|
||||
import { BarChart3, CalendarDays, CarFront, Clock3, RefreshCw, Sun } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DatePicker } from '@/components/ResponsivePickers';
|
||||
import { timeApi } from '@/api/time';
|
||||
import { dateKey, formatMinutes } from '@/components/planning/utils';
|
||||
|
||||
const PERIODS = [
|
||||
{ key: 'day', label: 'Jour' },
|
||||
{ key: 'month', label: 'Mois' },
|
||||
{ key: 'year', label: 'Année' },
|
||||
];
|
||||
import StatisticsPeriodSelector from '@/components/statistics/StatisticsPeriodSelector';
|
||||
|
||||
const StatCard = ({ icon: Icon, label, value }) => (
|
||||
<div className="rounded-xl border bg-background/50 p-4">
|
||||
@ -37,6 +31,12 @@ const rangeLabel = (stats) => {
|
||||
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') {
|
||||
return start.toLocaleDateString('fr-FR', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
@ -72,8 +72,6 @@ export default function PersonalStatistics() {
|
||||
return () => controller.abort();
|
||||
}, [period, anchor, reload]);
|
||||
|
||||
const monthValue = anchor.slice(0, 7);
|
||||
const yearValue = anchor.slice(0, 4);
|
||||
|
||||
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">
|
||||
@ -94,29 +92,12 @@ export default function PersonalStatistics() {
|
||||
</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="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={() => setPeriod(item.key)}>
|
||||
{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>
|
||||
<StatisticsPeriodSelector
|
||||
period={period}
|
||||
anchor={anchor}
|
||||
onPeriodChange={setPeriod}
|
||||
onAnchorChange={setAnchor}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
158
frontend/src/components/statistics/StatisticsPeriodSelector.jsx
Normal file
158
frontend/src/components/statistics/StatisticsPeriodSelector.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -2,19 +2,13 @@ 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 { DatePicker } from '@/components/ResponsivePickers';
|
||||
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';
|
||||
|
||||
const PERIODS = [
|
||||
{ key: 'day', label: 'Jour' },
|
||||
{ key: 'month', label: 'Mois' },
|
||||
{ key: 'year', label: 'Année' },
|
||||
];
|
||||
import StatisticsPeriodSelector from '@/components/statistics/StatisticsPeriodSelector';
|
||||
|
||||
const StatCard = ({ icon: Icon, label, value, detail }) => (
|
||||
<div className="rounded-xl border bg-card p-4 shadow-sm">
|
||||
@ -38,6 +32,11 @@ const rangeLabel = (stats) => {
|
||||
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' });
|
||||
}
|
||||
@ -102,8 +101,6 @@ export default function UserStatisticsPage() {
|
||||
return <LoadingCard icon={BarChart3} title="Statistiques" message="Chargement des salariés…" />;
|
||||
}
|
||||
|
||||
const monthValue = anchor.slice(0, 7);
|
||||
const yearValue = anchor.slice(0, 4);
|
||||
|
||||
return (
|
||||
<MainCard title="Statistiques" icon={BarChart3}>
|
||||
@ -119,29 +116,12 @@ export default function UserStatisticsPage() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<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={() => setPeriod(item.key)}>
|
||||
{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>
|
||||
<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' : ''}`} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user