243 lines
12 KiB
TypeScript
243 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useState, Fragment } from "react";
|
|
import { Popover, PopoverButton, PopoverPanel, Transition } from "@headlessui/react";
|
|
import { CalendarIcon, ChevronLeftIcon, ChevronRightIcon, ClockIcon } from "@heroicons/react/24/outline";
|
|
import {
|
|
format,
|
|
addMonths,
|
|
subMonths,
|
|
startOfMonth,
|
|
endOfMonth,
|
|
startOfWeek,
|
|
endOfWeek,
|
|
isSameMonth,
|
|
isSameDay,
|
|
addDays,
|
|
eachDayOfInterval,
|
|
isWithinInterval,
|
|
isBefore,
|
|
subDays
|
|
} from "date-fns";
|
|
import { ptBR } from "date-fns/locale";
|
|
|
|
interface DatePickerProps {
|
|
value?: { start: Date | null; end: Date | null } | Date | null;
|
|
onChange: (val: any) => void;
|
|
placeholder?: string;
|
|
className?: string;
|
|
buttonClassName?: string;
|
|
mode?: 'single' | 'range';
|
|
label?: string;
|
|
}
|
|
|
|
export default function DatePicker({
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
className = "",
|
|
buttonClassName = "",
|
|
mode = 'range',
|
|
label
|
|
}: DatePickerProps) {
|
|
const [currentMonth, setCurrentMonth] = useState(new Date());
|
|
|
|
// Helper to normalize value
|
|
const getRange = () => {
|
|
if (mode === 'single') {
|
|
const date = value instanceof Date ? value : (value as any)?.start || null;
|
|
return { start: date, end: date };
|
|
}
|
|
const range = (value as { start: Date | null; end: Date | null }) || { start: null, end: null };
|
|
return range;
|
|
};
|
|
|
|
const range = getRange();
|
|
|
|
const quickRanges = [
|
|
{ label: 'Hoje', getValue: () => ({ start: new Date(), end: new Date() }) },
|
|
{ label: 'Últimos 7 dias', getValue: () => ({ start: subDays(new Date(), 7), end: new Date() }) },
|
|
{ label: 'Últimos 14 dias', getValue: () => ({ start: subDays(new Date(), 14), end: new Date() }) },
|
|
{ label: 'Últimos 30 dias', getValue: () => ({ start: subDays(new Date(), 30), end: new Date() }) },
|
|
{ label: 'Este Mês', getValue: () => ({ start: startOfMonth(new Date()), end: endOfMonth(new Date()) }) },
|
|
];
|
|
|
|
const handleDateClick = (day: Date) => {
|
|
if (mode === 'single') {
|
|
onChange(day);
|
|
return;
|
|
}
|
|
|
|
if (!range.start || (range.start && range.end)) {
|
|
onChange({ start: day, end: null });
|
|
} else {
|
|
if (isBefore(day, range.start)) {
|
|
onChange({ start: day, end: range.start });
|
|
} else {
|
|
onChange({ start: range.start, end: day });
|
|
}
|
|
}
|
|
};
|
|
|
|
const isInRange = (day: Date) => {
|
|
if (range.start && range.end) {
|
|
return isWithinInterval(day, { start: range.start, end: range.end });
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const displayValue = () => {
|
|
if (!range.start) return placeholder || (mode === 'single' ? "Selecionar data" : "Selecionar período");
|
|
if (mode === 'single') return format(range.start, "dd/MM/yyyy", { locale: ptBR });
|
|
if (!range.end || isSameDay(range.start, range.end)) return format(range.start, "dd MMM yyyy", { locale: ptBR });
|
|
return `${format(range.start, "dd MMM", { locale: ptBR })} - ${format(range.end, "dd MMM yyyy", { locale: ptBR })}`;
|
|
};
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
onChange(mode === 'single' ? null : { start: null, end: null });
|
|
};
|
|
|
|
return (
|
|
<Popover className={`relative ${className}`}>
|
|
{label && (
|
|
<label className="block text-sm font-bold text-zinc-700 dark:text-zinc-300 mb-2">
|
|
{label}
|
|
</label>
|
|
)}
|
|
<PopoverButton
|
|
className={`
|
|
w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold transition-all outline-none border rounded-xl group
|
|
${buttonClassName || 'bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400'}
|
|
focus:border-zinc-400 dark:focus:border-zinc-500
|
|
`}
|
|
>
|
|
<CalendarIcon className="w-4.5 h-4.5 text-zinc-400 group-hover:text-brand-500 transition-colors shrink-0" />
|
|
<span className="flex-1 text-left truncate">
|
|
{displayValue()}
|
|
</span>
|
|
{range.start && (
|
|
<button
|
|
type="button"
|
|
onClick={handleClear}
|
|
className="p-1 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-lg text-zinc-400 hover:text-rose-500 transition-colors"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.6} d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
</PopoverButton>
|
|
|
|
<Transition
|
|
as={Fragment}
|
|
enter="transition ease-out duration-200"
|
|
enterFrom="opacity-0 translate-y-1"
|
|
enterTo="opacity-100 translate-y-0"
|
|
leave="transition ease-in duration-150"
|
|
leaveFrom="opacity-100 translate-y-0"
|
|
leaveTo="opacity-0 translate-y-1"
|
|
>
|
|
<PopoverPanel
|
|
anchor="bottom end"
|
|
className="flex bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-2xl shadow-xl overflow-hidden outline-none [--anchor-gap:8px] min-w-[480px]"
|
|
>
|
|
{/* Filtros Rápidos (Apenas para Range) */}
|
|
{mode === 'range' && (
|
|
<div className="w-48 bg-zinc-50/80 dark:bg-zinc-900/80 border-r border-zinc-100 dark:border-zinc-800 p-4 space-y-1.5">
|
|
<div className="flex items-center gap-2 px-1 py-1 mb-2">
|
|
<ClockIcon className="w-3.5 h-3.5 text-zinc-400" />
|
|
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-[0.1em]">Atalhos</p>
|
|
</div>
|
|
{quickRanges.map((r) => (
|
|
<button
|
|
key={r.label}
|
|
type="button"
|
|
onClick={() => onChange(r.getValue())}
|
|
className="w-full px-3 py-2 text-left text-[11px] font-bold text-zinc-600 dark:text-zinc-400 hover:bg-white dark:hover:bg-zinc-800 hover:text-brand-600 dark:hover:text-brand-400 rounded-xl transition-all border border-transparent hover:border-zinc-100 dark:hover:border-zinc-700 hover:shadow-sm"
|
|
>
|
|
{r.label}
|
|
</button>
|
|
))}
|
|
<div className="pt-4 mt-4 border-t border-zinc-100 dark:border-zinc-800">
|
|
<button
|
|
type="button"
|
|
onClick={() => onChange({ start: null, end: null })}
|
|
className="w-full px-3 py-2 text-left text-[11px] font-bold text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-all"
|
|
>
|
|
Limpar Filtro
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Calendário */}
|
|
<div className="flex-1 p-4">
|
|
<div className="flex items-center justify-between mb-4 px-1">
|
|
<span className="text-sm font-bold text-zinc-900 dark:text-white capitalize">
|
|
{format(currentMonth, "MMMM yyyy", { locale: ptBR })}
|
|
</span>
|
|
<div className="flex gap-1">
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrentMonth(subMonths(currentMonth, 1))}
|
|
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors border border-zinc-100 dark:border-zinc-800"
|
|
>
|
|
<ChevronLeftIcon className="w-4 h-4 text-zinc-500" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setCurrentMonth(addMonths(currentMonth, 1))}
|
|
className="p-1.5 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md transition-colors border border-zinc-100 dark:border-zinc-800"
|
|
>
|
|
<ChevronRightIcon className="w-4 h-4 text-zinc-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 mb-2">
|
|
{["D", "S", "T", "Q", "Q", "S", "S"].map((day, idx) => (
|
|
<div key={idx} className="text-center text-[10px] font-bold text-zinc-400 py-1">
|
|
{day}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="grid grid-cols-7 gap-1">
|
|
{eachDayOfInterval({
|
|
start: startOfWeek(startOfMonth(currentMonth)),
|
|
end: endOfWeek(endOfMonth(currentMonth))
|
|
}).map((day, idx) => {
|
|
const isStart = range.start && isSameDay(day, range.start);
|
|
const isEnd = range.end && isSameDay(day, range.end);
|
|
const isSelected = isStart || isEnd;
|
|
const isRange = isInRange(day);
|
|
const isCurrentMonth = isSameMonth(day, startOfMonth(currentMonth));
|
|
const isTodayDate = isSameDay(day, new Date());
|
|
|
|
return (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => handleDateClick(day)}
|
|
className={`
|
|
relative py-2.5 text-[11px] transition-all outline-none flex items-center justify-center font-bold h-10 w-10
|
|
${!isCurrentMonth ? 'text-zinc-300 dark:text-zinc-600' : 'text-zinc-900 dark:text-zinc-100'}
|
|
${isSelected ? 'bg-brand-500 text-white rounded-xl z-10 scale-105 shadow-lg shadow-brand-500/20' : ''}
|
|
${isRange && !isSelected && mode === 'range' ? 'bg-brand-50 dark:bg-brand-500/10 text-brand-600 dark:text-brand-400' : ''}
|
|
${!isSelected ? 'hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:rounded-xl' : ''}
|
|
${isTodayDate && !isSelected ? 'text-brand-600 ring-1 ring-brand-100 dark:ring-brand-500/30 rounded-xl' : ''}
|
|
`}
|
|
>
|
|
<span>{format(day, "d")}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</PopoverPanel>
|
|
</Transition>
|
|
</Popover>
|
|
);
|
|
}
|