nice
This commit is contained in:
parent
e46e3f8ed0
commit
a393d9409f
@ -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])
|
||||||
|
|||||||
@ -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 && (
|
||||||
|
|||||||
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 { 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' : ''}`} />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user