mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 04:08:11 +00:00
feat(freight-backoffice): add DateRangePicker, replace ad-hoc from/to date filters
Adds a shadcn Popover+Calendar range picker with presets (Today, Last 7/30 days, this/last month, YTD) and Apply/Cancel. Swaps it into every shadcn-family from-to date filter: activity log, sector reports, incoming records (EN branch), CombinedFilterBar (13 consumers), user records, audit log — also fixes an uncontrolled-input bug on the audit log date fields. Mantine-based ListControls/ReportFilters left untouched (different UI kit).
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
import * as React from "react";
|
||||
import { CalendarIcon, ChevronDown } from "lucide-react";
|
||||
import {
|
||||
format,
|
||||
isSameDay,
|
||||
isSameYear,
|
||||
startOfDay,
|
||||
endOfDay,
|
||||
startOfMonth,
|
||||
endOfMonth,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
} from "date-fns";
|
||||
|
||||
import { cn } from "@/shared/lib/utils";
|
||||
import { Button } from "@/shared/common/ui/button";
|
||||
import { Calendar } from "@/shared/common/ui/calendar";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/shared/common/ui/popover";
|
||||
|
||||
export interface DateRangeValue {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
}
|
||||
|
||||
/** Parse a `YYYY-MM-DD` string as a local calendar day (avoids the UTC-midnight timezone shift `new Date(str)` has). */
|
||||
export function parseDay(value?: string | null): Date | undefined {
|
||||
if (!value) return undefined;
|
||||
const [y, m, d] = value.split("-").map(Number);
|
||||
if (!y || !m || !d) return undefined;
|
||||
return new Date(y, m - 1, d);
|
||||
}
|
||||
|
||||
/** Format a `Date` back to `YYYY-MM-DD`, for call sites still storing plain date strings. */
|
||||
export function formatDay(date?: Date): string {
|
||||
return date ? format(date, "yyyy-MM-dd") : "";
|
||||
}
|
||||
|
||||
interface DateRangePreset {
|
||||
label: string;
|
||||
range: () => DateRangeValue;
|
||||
}
|
||||
|
||||
const DEFAULT_PRESETS: DateRangePreset[] = [
|
||||
{ label: "Today", range: () => ({ from: startOfDay(new Date()), to: endOfDay(new Date()) }) },
|
||||
{
|
||||
label: "Yesterday",
|
||||
range: () => ({ from: startOfDay(subDays(new Date(), 1)), to: endOfDay(subDays(new Date(), 1)) }),
|
||||
},
|
||||
{ label: "Last 7 days", range: () => ({ from: startOfDay(subDays(new Date(), 6)), to: endOfDay(new Date()) }) },
|
||||
{ label: "Last 30 days", range: () => ({ from: startOfDay(subDays(new Date(), 29)), to: endOfDay(new Date()) }) },
|
||||
{ label: "This month", range: () => ({ from: startOfMonth(new Date()), to: endOfDay(new Date()) }) },
|
||||
{
|
||||
label: "Last month",
|
||||
range: () => ({
|
||||
from: startOfMonth(subMonths(new Date(), 1)),
|
||||
to: endOfMonth(subMonths(new Date(), 1)),
|
||||
}),
|
||||
},
|
||||
{ label: "Year to date", range: () => ({ from: startOfYear(new Date()), to: endOfDay(new Date()) }) },
|
||||
];
|
||||
|
||||
function formatRangeLabel(value?: DateRangeValue, placeholder = "Select date range") {
|
||||
if (!value?.from) return placeholder;
|
||||
if (!value.to || isSameDay(value.from, value.to)) {
|
||||
return format(value.from, "MMM d, y");
|
||||
}
|
||||
if (isSameYear(value.from, value.to)) {
|
||||
return `${format(value.from, "MMM d")} – ${format(value.to, "MMM d, y")}`;
|
||||
}
|
||||
return `${format(value.from, "MMM d, y")} – ${format(value.to, "MMM d, y")}`;
|
||||
}
|
||||
|
||||
interface DateRangePickerProps {
|
||||
value?: DateRangeValue;
|
||||
onChange?: (range: DateRangeValue) => void;
|
||||
presets?: DateRangePreset[];
|
||||
placeholder?: string;
|
||||
numberOfMonths?: number;
|
||||
className?: string;
|
||||
align?: "start" | "center" | "end";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
presets = DEFAULT_PRESETS,
|
||||
placeholder = "Select date range",
|
||||
numberOfMonths = 2,
|
||||
className,
|
||||
align = "start",
|
||||
disabled = false,
|
||||
}: DateRangePickerProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [draft, setDraft] = React.useState<DateRangeValue | undefined>(value);
|
||||
|
||||
// Reset the draft to the committed value each time the popover opens
|
||||
React.useEffect(() => {
|
||||
if (open) setDraft(value);
|
||||
}, [open, value]);
|
||||
|
||||
const activePresetLabel = React.useMemo(() => {
|
||||
if (!draft?.from || !draft?.to) return null;
|
||||
const match = presets.find((preset) => {
|
||||
const r = preset.range();
|
||||
return r.from && r.to && isSameDay(r.from, draft.from!) && isSameDay(r.to, draft.to!);
|
||||
});
|
||||
return match?.label ?? null;
|
||||
}, [draft, presets]);
|
||||
|
||||
function applyPreset(preset: DateRangePreset) {
|
||||
const range = preset.range();
|
||||
setDraft(range);
|
||||
onChange?.(range);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
if (draft?.from) onChange?.({ from: draft.from, to: draft.to ?? draft.from });
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
setDraft(value);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open && !disabled} onOpenChange={(next: boolean) => setOpen(next && !disabled)}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"min-w-[240px] justify-start gap-2 font-normal text-slate-700 dark:text-slate-200",
|
||||
!value?.from && "text-slate-500 dark:text-slate-400",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<CalendarIcon className="size-4 text-slate-500 dark:text-slate-400" />
|
||||
<span className="flex-1 text-left">{formatRangeLabel(value, placeholder)}</span>
|
||||
<ChevronDown className="size-4 text-slate-400" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} className="w-auto p-0">
|
||||
<div className="flex flex-col sm:flex-row">
|
||||
<div className="flex shrink-0 flex-col gap-0.5 border-b p-2 sm:border-b-0 sm:border-r sm:w-[160px]">
|
||||
{presets.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => applyPreset(preset)}
|
||||
className={cn(
|
||||
"rounded-md px-2.5 py-1.5 text-left text-sm font-medium text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800",
|
||||
activePresetLabel === preset.label &&
|
||||
"bg-primary-50 text-primary-700 hover:bg-primary-50 dark:bg-primary-900/30 dark:text-primary-300",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col">
|
||||
<Calendar
|
||||
mode="range"
|
||||
selected={draft}
|
||||
onSelect={(range: DateRangeValue | undefined) => setDraft(range ?? undefined)}
|
||||
numberOfMonths={numberOfMonths}
|
||||
defaultMonth={draft?.from ?? value?.from}
|
||||
className="border-none shadow-none"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 border-t p-3">
|
||||
<span className="text-sm text-slate-500 dark:text-slate-400">
|
||||
{formatRangeLabel(draft, "No dates selected")}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="sm" disabled={!draft?.from} onClick={handleApply}>
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user