test
This commit is contained in:
parent
6e70e58a61
commit
4bf1370543
537
frontend/src/components/ResponsivePickers.jsx
Normal file
537
frontend/src/components/ResponsivePickers.jsx
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import { Calendar } from '@/components/ui/calendar';
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { CalendarDays, Clock } from 'lucide-react';
|
||||||
|
import { fr } from 'date-fns/locale';
|
||||||
|
|
||||||
|
const parseDateValue = (value) => {
|
||||||
|
if (!value) return undefined;
|
||||||
|
|
||||||
|
const [
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day,
|
||||||
|
] = value.split('-').map(Number);
|
||||||
|
|
||||||
|
if (!year || !month || !day) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(
|
||||||
|
year,
|
||||||
|
month - 1,
|
||||||
|
day,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const formatDateValue = (date) => {
|
||||||
|
if (!date) return '';
|
||||||
|
|
||||||
|
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 formatDateLabel = (value) => {
|
||||||
|
const date =
|
||||||
|
parseDateValue(value);
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
return 'Choisir une date';
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericDate =
|
||||||
|
date.toLocaleDateString(
|
||||||
|
'fr-FR',
|
||||||
|
{
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const longDate =
|
||||||
|
date.toLocaleDateString(
|
||||||
|
'fr-FR',
|
||||||
|
{
|
||||||
|
day: '2-digit',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="lg:hidden">
|
||||||
|
{longDate}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden lg:inline">
|
||||||
|
{numericDate}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const DesktopDatePicker = ({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
min,
|
||||||
|
placeholder = 'Choisir une date',
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const selectedDate =
|
||||||
|
parseDateValue(value);
|
||||||
|
|
||||||
|
const minDate =
|
||||||
|
parseDateValue(min);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-2 font-normal"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
selectedDate
|
||||||
|
? ''
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{selectedDate
|
||||||
|
? formatDateLabel(value)
|
||||||
|
: placeholder}
|
||||||
|
</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-auto p-0"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
locale={fr}
|
||||||
|
weekStartsOn={1}
|
||||||
|
selected={selectedDate}
|
||||||
|
defaultMonth={
|
||||||
|
selectedDate ||
|
||||||
|
minDate ||
|
||||||
|
new Date()
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
minDate
|
||||||
|
? {
|
||||||
|
before: minDate,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
onSelect={(date) => {
|
||||||
|
if (!date) return;
|
||||||
|
|
||||||
|
onChange(
|
||||||
|
formatDateValue(date),
|
||||||
|
);
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
/* =========================================================
|
||||||
|
TIME PICKER
|
||||||
|
========================================================= */
|
||||||
|
|
||||||
|
const DesktopTimePicker = ({
|
||||||
|
value,
|
||||||
|
onChange
|
||||||
|
}) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const timeRef = useRef(value);
|
||||||
|
timeRef.current = value;
|
||||||
|
const mouseState = useRef({ active: false, lastY: 0, accumulated: 0 });
|
||||||
|
|
||||||
|
const touchState = useRef({
|
||||||
|
active: false,
|
||||||
|
lastY: 0,
|
||||||
|
accumulated: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [hours = '', minutes = ''] = value
|
||||||
|
? value.split(':')
|
||||||
|
: [];
|
||||||
|
|
||||||
|
const hourOptions = Array.from(
|
||||||
|
{ length: 24 },
|
||||||
|
(_, i) => String(i).padStart(2, '0'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const minuteOptions = Array.from(
|
||||||
|
{ length: 60 },
|
||||||
|
(_, i) => String(i).padStart(2, '0'),
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTime = (
|
||||||
|
nextHours,
|
||||||
|
nextMinutes,
|
||||||
|
) => {
|
||||||
|
const nextValue = `${String(nextHours).padStart(2, '0')}:${String(
|
||||||
|
nextMinutes,
|
||||||
|
).padStart(2, '0')}`;
|
||||||
|
timeRef.current = nextValue;
|
||||||
|
onChange(nextValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeHour = (direction) => {
|
||||||
|
const [currentHour = 0, currentMinute = 0] =
|
||||||
|
(timeRef.current || '00:00').split(':').map(Number);
|
||||||
|
updateTime((currentHour + direction % 24 + 24) % 24, currentMinute);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeMinute = (direction) => {
|
||||||
|
const [currentHour = 0, currentMinute = 0] =
|
||||||
|
(timeRef.current || '00:00').split(':').map(Number);
|
||||||
|
const total = ((currentHour * 60 + currentMinute + direction) % 1440 + 1440) % 1440;
|
||||||
|
updateTime(Math.floor(total / 60), total % 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseDown = (event) => {
|
||||||
|
if (event.pointerType !== 'mouse' || event.button !== 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
mouseState.current = { active: true, lastY: event.clientY, accumulated: 0 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseMove = (event, type) => {
|
||||||
|
if (!mouseState.current.active || event.pointerType !== 'mouse') return;
|
||||||
|
const state = mouseState.current;
|
||||||
|
state.accumulated += state.lastY - event.clientY;
|
||||||
|
state.lastY = event.clientY;
|
||||||
|
const steps = Math.trunc(state.accumulated / 18);
|
||||||
|
if (!steps) return;
|
||||||
|
state.accumulated -= steps * 18;
|
||||||
|
if (type === 'hours') changeHour(steps);
|
||||||
|
else changeMinute(steps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseUp = () => {
|
||||||
|
mouseState.current.active = false;
|
||||||
|
mouseState.current.accumulated = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWheel = (event, type) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
|
||||||
|
const direction =
|
||||||
|
event.deltaY > 0 ? 1 : -1;
|
||||||
|
|
||||||
|
if (type === 'hours') {
|
||||||
|
changeHour(direction);
|
||||||
|
} else {
|
||||||
|
changeMinute(direction);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchStart = (event) => {
|
||||||
|
const touch = event.touches[0];
|
||||||
|
|
||||||
|
touchState.current = {
|
||||||
|
active: true,
|
||||||
|
lastY: touch.clientY,
|
||||||
|
accumulated: 0,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = (event, type) => {
|
||||||
|
if (!touchState.current.active) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const touch = event.touches[0];
|
||||||
|
const currentY = touch.clientY;
|
||||||
|
|
||||||
|
const deltaY =
|
||||||
|
touchState.current.lastY - currentY;
|
||||||
|
|
||||||
|
touchState.current.lastY = currentY;
|
||||||
|
|
||||||
|
touchState.current.accumulated += deltaY;
|
||||||
|
|
||||||
|
const pixelsPerUnit = 18;
|
||||||
|
|
||||||
|
const steps = Math.trunc(
|
||||||
|
touchState.current.accumulated /
|
||||||
|
pixelsPerUnit,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (steps === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
touchState.current.accumulated -=
|
||||||
|
steps * pixelsPerUnit;
|
||||||
|
|
||||||
|
if (type === 'hours') {
|
||||||
|
changeHour(steps);
|
||||||
|
} else {
|
||||||
|
changeMinute(steps);
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchEnd = () => {
|
||||||
|
touchState.current.active = false;
|
||||||
|
touchState.current.accumulated = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTime = () => {
|
||||||
|
onChange('');
|
||||||
|
setOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
open={open}
|
||||||
|
onOpenChange={setOpen}
|
||||||
|
>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-2 font-normal"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Clock className="size-4 text-muted-foreground" />
|
||||||
|
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
value
|
||||||
|
? 'tabular-nums'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{value ? (
|
||||||
|
value
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="lg:hidden">
|
||||||
|
Choisir une heure
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden lg:inline">
|
||||||
|
Choisir
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</PopoverTrigger>
|
||||||
|
|
||||||
|
<PopoverContent
|
||||||
|
className="w-[190px] p-3"
|
||||||
|
align="start"
|
||||||
|
>
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
Choisir une heure
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{value || '--:--'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex h-[144px] gap-2 overflow-hidden">
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-9 -translate-y-1/2 rounded-md border bg-muted/50" />
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
|
onPointerDown={handleMouseDown}
|
||||||
|
onPointerMove={(event) => handleMouseMove(event, 'hours')}
|
||||||
|
onPointerUp={handleMouseUp}
|
||||||
|
onLostPointerCapture={handleMouseUp}
|
||||||
|
onWheel={(event) =>
|
||||||
|
handleWheel(
|
||||||
|
event,
|
||||||
|
'hours',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={(event) =>
|
||||||
|
handleTouchMove(
|
||||||
|
event,
|
||||||
|
'hours',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchCancel={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<span className="h-9 text-sm text-muted-foreground/40">
|
||||||
|
{
|
||||||
|
hourOptions[
|
||||||
|
(Number(hours || 0) +
|
||||||
|
23) %
|
||||||
|
24
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{hours || '00'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="h-9 text-sm text-muted-foreground/40">
|
||||||
|
{
|
||||||
|
hourOptions[
|
||||||
|
(Number(hours || 0) +
|
||||||
|
1) %
|
||||||
|
24
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-center">
|
||||||
|
<span className="font-bold">
|
||||||
|
:
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex-1 cursor-grab touch-none select-none active:cursor-grabbing"
|
||||||
|
onPointerDown={handleMouseDown}
|
||||||
|
onPointerMove={(event) => handleMouseMove(event, 'minutes')}
|
||||||
|
onPointerUp={handleMouseUp}
|
||||||
|
onLostPointerCapture={handleMouseUp}
|
||||||
|
onWheel={(event) =>
|
||||||
|
handleWheel(
|
||||||
|
event,
|
||||||
|
'minutes',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onTouchStart={handleTouchStart}
|
||||||
|
onTouchMove={(event) =>
|
||||||
|
handleTouchMove(
|
||||||
|
event,
|
||||||
|
'minutes',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onTouchEnd={handleTouchEnd}
|
||||||
|
onTouchCancel={handleTouchEnd}
|
||||||
|
>
|
||||||
|
<div className="flex h-full flex-col items-center justify-center">
|
||||||
|
<span className="h-9 text-sm text-muted-foreground/40">
|
||||||
|
{
|
||||||
|
minuteOptions[
|
||||||
|
(Number(minutes || 0) +
|
||||||
|
59) %
|
||||||
|
60
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{minutes || '00'}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="h-9 text-sm text-muted-foreground/40">
|
||||||
|
{
|
||||||
|
minuteOptions[
|
||||||
|
(Number(minutes || 0) +
|
||||||
|
1) %
|
||||||
|
60
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-2 text-center text-[10px] text-muted-foreground">
|
||||||
|
Glissez pour modifier
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-2 border-t pt-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-full text-xs"
|
||||||
|
disabled={!value}
|
||||||
|
onClick={clearTime}
|
||||||
|
>
|
||||||
|
Effacer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Les champs natifs ouvrent le sélecteur du téléphone.
|
||||||
|
// Sur ordinateur, on conserve le calendrier et la molette de TimeClock.
|
||||||
|
export const DatePicker = ({ value, onChange, min, placeholder, id }) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="date"
|
||||||
|
min={min}
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="block w-full min-w-0 md:hidden"
|
||||||
|
/>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<DesktopDatePicker value={value} onChange={onChange} min={min} placeholder={placeholder} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TimePicker = ({ value, onChange, id }) => (
|
||||||
|
<>
|
||||||
|
<Input
|
||||||
|
id={id}
|
||||||
|
type="time"
|
||||||
|
value={value || ''}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="block w-full min-w-0 md:hidden"
|
||||||
|
/>
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<DesktopTimePicker value={value} onChange={onChange} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
@ -17,6 +17,7 @@ import LoadingCard from '@/components/LoadingCard';
|
|||||||
import MainCard from '@/components/MainCard';
|
import MainCard from '@/components/MainCard';
|
||||||
|
|
||||||
import HoverInfo from '@/components/HoverInfo';
|
import HoverInfo from '@/components/HoverInfo';
|
||||||
|
import { DatePicker, TimePicker } from '@/components/ResponsivePickers';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
@ -2358,27 +2359,21 @@ const CalendarPage = () => {
|
|||||||
|
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<DatePicker
|
||||||
|
|
||||||
id="event-start-date"
|
id="event-start-date"
|
||||||
|
|
||||||
type="date"
|
|
||||||
|
|
||||||
value={
|
value={
|
||||||
|
|
||||||
eventForm.start_date
|
eventForm.start_date
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange={(
|
onChange={(value) =>
|
||||||
|
|
||||||
event,
|
|
||||||
|
|
||||||
) =>
|
|
||||||
|
|
||||||
handleStartDateChange(
|
handleStartDateChange(
|
||||||
|
|
||||||
event.target.value,
|
value,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2396,12 +2391,10 @@ const CalendarPage = () => {
|
|||||||
|
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<DatePicker
|
||||||
|
|
||||||
id="event-end-date"
|
id="event-end-date"
|
||||||
|
|
||||||
type="date"
|
|
||||||
|
|
||||||
min={
|
min={
|
||||||
|
|
||||||
eventForm.start_date ||
|
eventForm.start_date ||
|
||||||
@ -2416,17 +2409,13 @@ const CalendarPage = () => {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange={(
|
onChange={(value) =>
|
||||||
|
|
||||||
event,
|
|
||||||
|
|
||||||
) =>
|
|
||||||
|
|
||||||
handleFormChange(
|
handleFormChange(
|
||||||
|
|
||||||
'end_date',
|
'end_date',
|
||||||
|
|
||||||
event.target.value,
|
value,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2456,29 +2445,23 @@ const CalendarPage = () => {
|
|||||||
|
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<TimePicker
|
||||||
|
|
||||||
id="event-start-time"
|
id="event-start-time"
|
||||||
|
|
||||||
type="time"
|
|
||||||
|
|
||||||
value={
|
value={
|
||||||
|
|
||||||
eventForm.start_time
|
eventForm.start_time
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange={(
|
onChange={(value) =>
|
||||||
|
|
||||||
event,
|
|
||||||
|
|
||||||
) =>
|
|
||||||
|
|
||||||
handleFormChange(
|
handleFormChange(
|
||||||
|
|
||||||
'start_time',
|
'start_time',
|
||||||
|
|
||||||
event.target.value,
|
value,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -2502,29 +2485,23 @@ const CalendarPage = () => {
|
|||||||
|
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<Input
|
<TimePicker
|
||||||
|
|
||||||
id="event-end-time"
|
id="event-end-time"
|
||||||
|
|
||||||
type="time"
|
|
||||||
|
|
||||||
value={
|
value={
|
||||||
|
|
||||||
eventForm.end_time
|
eventForm.end_time
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange={(
|
onChange={(value) =>
|
||||||
|
|
||||||
event,
|
|
||||||
|
|
||||||
) =>
|
|
||||||
|
|
||||||
handleFormChange(
|
handleFormChange(
|
||||||
|
|
||||||
'end_time',
|
'end_time',
|
||||||
|
|
||||||
event.target.value,
|
value,
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import {
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||||
|
import { DatePicker, TimePicker } from '@/components/ResponsivePickers';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -39,16 +40,9 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
import { Calendar } from '@/components/ui/calendar';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from '@/components/ui/popover';
|
|
||||||
|
|
||||||
import {
|
|
||||||
CalendarDays,
|
|
||||||
Clock,
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@ -95,24 +89,6 @@ const parseDateValue = (value) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const formatDateValue = (date) => {
|
|
||||||
if (!date) return '';
|
|
||||||
|
|
||||||
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 formatDateLabel = (value) => {
|
const formatDateLabel = (value) => {
|
||||||
const date =
|
const date =
|
||||||
parseDateValue(value);
|
parseDateValue(value);
|
||||||
@ -183,426 +159,6 @@ const formatDate = (
|
|||||||
DATE PICKER
|
DATE PICKER
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|
||||||
const DatePicker = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
min,
|
|
||||||
placeholder = 'Choisir une date',
|
|
||||||
}) => {
|
|
||||||
const [open, setOpen] =
|
|
||||||
useState(false);
|
|
||||||
|
|
||||||
const selectedDate =
|
|
||||||
parseDateValue(value);
|
|
||||||
|
|
||||||
const minDate =
|
|
||||||
parseDateValue(min);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start gap-2 font-normal"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<CalendarDays className="size-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
selectedDate
|
|
||||||
? ''
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{selectedDate
|
|
||||||
? formatDateLabel(value)
|
|
||||||
: placeholder}
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
className="w-auto p-0"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<Calendar
|
|
||||||
mode="single"
|
|
||||||
selected={selectedDate}
|
|
||||||
defaultMonth={
|
|
||||||
selectedDate ||
|
|
||||||
minDate ||
|
|
||||||
new Date()
|
|
||||||
}
|
|
||||||
disabled={
|
|
||||||
minDate
|
|
||||||
? {
|
|
||||||
before: minDate,
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
onSelect={(date) => {
|
|
||||||
if (!date) return;
|
|
||||||
|
|
||||||
onChange(
|
|
||||||
formatDateValue(date),
|
|
||||||
);
|
|
||||||
|
|
||||||
setOpen(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/* =========================================================
|
|
||||||
TIME PICKER
|
|
||||||
========================================================= */
|
|
||||||
|
|
||||||
const TimePicker = ({
|
|
||||||
value,
|
|
||||||
onChange
|
|
||||||
}) => {
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
|
|
||||||
const touchState = useRef({
|
|
||||||
active: false,
|
|
||||||
lastY: 0,
|
|
||||||
accumulated: 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [hours = '', minutes = ''] = value
|
|
||||||
? value.split(':')
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const hourOptions = Array.from(
|
|
||||||
{ length: 24 },
|
|
||||||
(_, i) => String(i).padStart(2, '0'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const minuteOptions = Array.from(
|
|
||||||
{ length: 60 },
|
|
||||||
(_, i) => String(i).padStart(2, '0'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const updateTime = (
|
|
||||||
nextHours,
|
|
||||||
nextMinutes,
|
|
||||||
) => {
|
|
||||||
onChange(
|
|
||||||
`${String(nextHours).padStart(2, '0')}:${String(
|
|
||||||
nextMinutes,
|
|
||||||
).padStart(2, '0')}`,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeHour = (direction) => {
|
|
||||||
const currentHour = Number(hours || 0);
|
|
||||||
const currentMinute = Number(minutes || 0);
|
|
||||||
|
|
||||||
let nextHour =
|
|
||||||
currentHour + direction;
|
|
||||||
|
|
||||||
if (nextHour < 0) {
|
|
||||||
nextHour = 23;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextHour > 23) {
|
|
||||||
nextHour = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTime(
|
|
||||||
nextHour,
|
|
||||||
currentMinute,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const changeMinute = (direction) => {
|
|
||||||
const currentHour = Number(hours || 0);
|
|
||||||
const currentMinute = Number(minutes || 0);
|
|
||||||
|
|
||||||
let nextMinute =
|
|
||||||
currentMinute + direction;
|
|
||||||
|
|
||||||
let nextHour = currentHour;
|
|
||||||
|
|
||||||
if (nextMinute < 0) {
|
|
||||||
nextMinute = 59;
|
|
||||||
|
|
||||||
nextHour =
|
|
||||||
currentHour === 0
|
|
||||||
? 23
|
|
||||||
: currentHour - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextMinute > 59) {
|
|
||||||
nextMinute = 0;
|
|
||||||
|
|
||||||
nextHour =
|
|
||||||
currentHour === 23
|
|
||||||
? 0
|
|
||||||
: currentHour + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateTime(
|
|
||||||
nextHour,
|
|
||||||
nextMinute,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleWheel = (event, type) => {
|
|
||||||
event.stopPropagation();
|
|
||||||
|
|
||||||
const direction =
|
|
||||||
event.deltaY > 0 ? 1 : -1;
|
|
||||||
|
|
||||||
if (type === 'hours') {
|
|
||||||
changeHour(direction);
|
|
||||||
} else {
|
|
||||||
changeMinute(direction);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchStart = (event) => {
|
|
||||||
const touch = event.touches[0];
|
|
||||||
|
|
||||||
touchState.current = {
|
|
||||||
active: true,
|
|
||||||
lastY: touch.clientY,
|
|
||||||
accumulated: 0,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = (event, type) => {
|
|
||||||
if (!touchState.current.active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const touch = event.touches[0];
|
|
||||||
const currentY = touch.clientY;
|
|
||||||
|
|
||||||
const deltaY =
|
|
||||||
touchState.current.lastY - currentY;
|
|
||||||
|
|
||||||
touchState.current.lastY = currentY;
|
|
||||||
|
|
||||||
touchState.current.accumulated += deltaY;
|
|
||||||
|
|
||||||
const pixelsPerUnit = 18;
|
|
||||||
|
|
||||||
const steps = Math.trunc(
|
|
||||||
touchState.current.accumulated /
|
|
||||||
pixelsPerUnit,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (steps === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
touchState.current.accumulated -=
|
|
||||||
steps * pixelsPerUnit;
|
|
||||||
|
|
||||||
if (type === 'hours') {
|
|
||||||
changeHour(steps);
|
|
||||||
} else {
|
|
||||||
changeMinute(steps);
|
|
||||||
}
|
|
||||||
|
|
||||||
event.stopPropagation();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchEnd = () => {
|
|
||||||
touchState.current.active = false;
|
|
||||||
touchState.current.accumulated = 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearTime = () => {
|
|
||||||
onChange('');
|
|
||||||
setOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover
|
|
||||||
open={open}
|
|
||||||
onOpenChange={setOpen}
|
|
||||||
>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
className="w-full justify-start gap-2 font-normal"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Clock className="size-4 text-muted-foreground" />
|
|
||||||
|
|
||||||
<span
|
|
||||||
className={
|
|
||||||
value
|
|
||||||
? 'tabular-nums'
|
|
||||||
: 'text-muted-foreground'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{value ? (
|
|
||||||
value
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<span className="lg:hidden">
|
|
||||||
Choisir une heure
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="hidden lg:inline">
|
|
||||||
Choisir
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</PopoverTrigger>
|
|
||||||
|
|
||||||
<PopoverContent
|
|
||||||
className="w-[190px] p-3"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex items-center justify-between">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
Choisir une heure
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="font-mono text-sm font-semibold tabular-nums">
|
|
||||||
{value || '--:--'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative flex h-[144px] gap-2 overflow-hidden">
|
|
||||||
<div className="pointer-events-none absolute inset-x-0 top-1/2 z-10 h-9 -translate-y-1/2 rounded-md border bg-muted/50" />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex-1 touch-none select-none"
|
|
||||||
onWheel={(event) =>
|
|
||||||
handleWheel(
|
|
||||||
event,
|
|
||||||
'hours',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={(event) =>
|
|
||||||
handleTouchMove(
|
|
||||||
event,
|
|
||||||
'hours',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchCancel={handleTouchEnd}
|
|
||||||
>
|
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
|
||||||
<span className="h-9 text-sm text-muted-foreground/40">
|
|
||||||
{
|
|
||||||
hourOptions[
|
|
||||||
(Number(hours || 0) +
|
|
||||||
23) %
|
|
||||||
24
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
|
|
||||||
{hours || '00'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="h-9 text-sm text-muted-foreground/40">
|
|
||||||
{
|
|
||||||
hourOptions[
|
|
||||||
(Number(hours || 0) +
|
|
||||||
1) %
|
|
||||||
24
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<span className="font-bold">
|
|
||||||
:
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="flex-1 touch-none select-none"
|
|
||||||
onWheel={(event) =>
|
|
||||||
handleWheel(
|
|
||||||
event,
|
|
||||||
'minutes',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onTouchStart={handleTouchStart}
|
|
||||||
onTouchMove={(event) =>
|
|
||||||
handleTouchMove(
|
|
||||||
event,
|
|
||||||
'minutes',
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onTouchEnd={handleTouchEnd}
|
|
||||||
onTouchCancel={handleTouchEnd}
|
|
||||||
>
|
|
||||||
<div className="flex h-full flex-col items-center justify-center">
|
|
||||||
<span className="h-9 text-sm text-muted-foreground/40">
|
|
||||||
{
|
|
||||||
minuteOptions[
|
|
||||||
(Number(minutes || 0) +
|
|
||||||
59) %
|
|
||||||
60
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="flex h-9 w-full items-center justify-center rounded-md font-mono text-sm font-semibold tabular-nums">
|
|
||||||
{minutes || '00'}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className="h-9 text-sm text-muted-foreground/40">
|
|
||||||
{
|
|
||||||
minuteOptions[
|
|
||||||
(Number(minutes || 0) +
|
|
||||||
1) %
|
|
||||||
60
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="mt-2 text-center text-[10px] text-muted-foreground">
|
|
||||||
Glissez pour modifier
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="mt-2 border-t pt-2">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 w-full text-xs"
|
|
||||||
disabled={!value}
|
|
||||||
onClick={clearTime}
|
|
||||||
>
|
|
||||||
Effacer
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
/* =========================================================
|
/* =========================================================
|
||||||
PAGE
|
PAGE
|
||||||
========================================================= */
|
========================================================= */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user