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:
Nathnael
2026-08-13 08:33:59 +00:00
parent 29913259f6
commit 6f14cf8bbf
8 changed files with 252 additions and 214 deletions

View File

@@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectTrigger,
@@ -134,21 +135,12 @@ const buildQuery = (): CollectionQueryDTO => {
className="w-64"
/>
<div className="flex gap-2 items-center">
<Input
type="date"
onChange={(e) =>
setDateRange({ ...dateRange, start: e.target.value })
}
/>
<span className="text-muted-foreground text-sm">to</span>
<Input
type="date"
onChange={(e) =>
setDateRange({ ...dateRange, end: e.target.value })
}
/>
</div>
<DateRangePicker
value={{ from: parseDay(dateRange.start), to: parseDay(dateRange.end) }}
onChange={(range) =>
setDateRange({ start: formatDay(range.from), end: formatDay(range.to) })
}
/>
<Select value={sort} onValueChange={setSort}>
<SelectTrigger className="w-[180px]">

View File

@@ -14,6 +14,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/shared/common/ui/select";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
export interface ReportFilterOption {
id: string;
@@ -167,33 +168,18 @@ export function SectorReportFilters({
</Select>
</label>
<label className="space-y-1.5 text-xs font-medium">
<label className="space-y-1.5 text-xs font-medium sm:col-span-2">
<span className="flex items-center gap-2">
<CalendarDays className="size-4 text-primary" />
From date
Date range
</span>
<input
type="date"
value={fromDate}
max={toDate || undefined}
<DateRangePicker
value={{ from: parseDay(fromDate), to: parseDay(toDate) }}
onChange={(range) => {
onFromDateChange(formatDay(range.from));
onToDateChange(formatDay(range.to));
}}
disabled={!unitId || loading}
onChange={(event) => onFromDateChange(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</label>
<label className="space-y-1.5 text-xs font-medium">
<span className="flex items-center gap-2">
<CalendarDays className="size-4 text-primary" />
To date
</span>
<input
type="date"
value={toDate}
min={fromDate || undefined}
disabled={!unitId || loading}
onChange={(event) => onToDateChange(event.target.value)}
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</label>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { DataTable } from "@/record-management/common/DataTable";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectContent,
@@ -383,23 +384,13 @@ const ExternalIncomingRecords = () => {
) : (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDispatchedDate}
onChange={(e) => setFromDispatchedDate(e.target.value)}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDispatchedDate}
onChange={(e) => setToDispatchedDate(e.target.value)}
min={fromDispatchedDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDispatchedDate), to: parseDay(toDispatchedDate) }}
onChange={(range) => {
setFromDispatchedDate(formatDay(range.from));
setToDispatchedDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
{/* From Date */}

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useMemo, useCallback } from "react";
import { DataTable } from "@/record-management/common/DataTable";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectContent,
@@ -326,23 +327,13 @@ const InternalIncomingRecordsV2 = () => {
) : (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDispatchedDate}
onChange={(e) => setFromDispatchedDate(e.target.value)}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDispatchedDate}
onChange={(e) => setToDispatchedDate(e.target.value)}
min={fromDispatchedDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDispatchedDate), to: parseDay(toDispatchedDate) }}
onChange={(range) => {
setFromDispatchedDate(formatDay(range.from));
setToDispatchedDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
{/* From Date */}

View File

@@ -17,6 +17,7 @@ import {
} from "@/shared/common/ui/select";
import { Input } from "@/shared/common/ui/input";
import { Button } from "@/shared/common/ui/button";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import { Badge } from "@/shared/common/ui/badge";
import { AdvancedTable } from "@/shared/common/ui/table/AdvancedTable";
import { useLocalizedName } from "@/shared/common/localizedName";
@@ -521,29 +522,13 @@ const UserRecords = () => {
{selectedFilter?.type === "dateRange" ? (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDate}
onChange={(e) => setFromDate(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddFilter();
}}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDate}
onChange={(e) => setToDate(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleAddFilter();
}}
min={fromDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDate), to: parseDay(toDate) }}
onChange={(range) => {
setFromDate(formatDay(range.from));
setToDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
<div className="relative">

View File

@@ -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>
);
}

View File

@@ -1,6 +1,5 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Input } from '@/shared/common/ui/input'
import {
@@ -11,14 +10,8 @@ import {
SelectValue,
} from '@/shared/common/ui/select'
import { Button } from '@/shared/common/ui/button'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/shared/common/ui/popover'
import { Calendar } from '@/shared/common/ui/calendar'
import { Search, Filter, CalendarIcon, X } from 'lucide-react'
import { format } from 'date-fns'
import { DateRangePicker } from '@/shared/common/ui/date-range-picker'
import { Search, Filter, X } from 'lucide-react'
import { cn } from '@/shared/lib/utils'
interface ActivityFiltersProps {
@@ -53,7 +46,6 @@ export function ActivityFilters({
onClearFilters,
}: ActivityFiltersProps) {
const { t } = useTranslation()
const [datePickerOpen, setDatePickerOpen] = useState(false)
const textStrong = 'text-gray-900 dark:text-gray-100'
const textMuted = 'text-gray-500 dark:text-gray-400'
@@ -168,92 +160,11 @@ export function ActivityFilters({
</div>
<div className="flex items-center gap-4">
<Popover open={datePickerOpen} onOpenChange={setDatePickerOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
className={cn(
'w-full md:w-auto justify-start text-left font-normal',
border,
surface,
textStrong,
)}
>
<CalendarIcon
className={cn('mr-2 h-4 w-4', textMuted)}
/>
{dateRange.from ? (
dateRange.to ? (
<>
{format(dateRange.from, 'LLL dd, y')} -{' '}
{format(dateRange.to, 'LLL dd, y')}
</>
) : (
format(dateRange.from, 'LLL dd, y')
)
) : (
<span className={textMuted}>
{t('auditLog.filters.dateRange')}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className={cn('w-auto p-0', border, surface)}
align="start"
>
<Calendar
initialFocus
mode="range"
defaultMonth={dateRange.from}
selected={{
from: dateRange.from,
to: dateRange.to,
}}
onSelect={(range) => {
onDateRangeChange({
from: range?.from,
to: range?.to,
})
}}
numberOfMonths={2}
/>
</PopoverContent>
</Popover>
<Button
variant="outline"
onClick={() => {
const today = new Date()
const last7Days = new Date(today)
last7Days.setDate(last7Days.getDate() - 7)
onDateRangeChange({
from: last7Days,
to: today,
})
}}
className={cn(border, surface, textStrong, hoverText)}
>
{t('auditLog.filters.last7Days')}
</Button>
<Button
variant="outline"
onClick={() => {
const today = new Date()
const last30Days = new Date(today)
last30Days.setDate(last30Days.getDate() - 30)
onDateRangeChange({
from: last30Days,
to: today,
})
}}
className={cn(border, surface, textStrong, hoverText)}
>
{t('auditLog.filters.last30Days')}
</Button>
<DateRangePicker
value={dateRange}
onChange={onDateRangeChange}
placeholder={t('auditLog.filters.dateRange')}
/>
</div>
</div>
)

View File

@@ -8,6 +8,7 @@ import EthiopianDatePickerModal, {
import { Badge } from "@/shared/common/ui/badge";
import { Button } from "@/shared/common/ui/button";
import { Input } from "@/shared/common/ui/input";
import { DateRangePicker, parseDay, formatDay } from "@/shared/common/ui/date-range-picker";
import {
Select,
SelectContent,
@@ -225,29 +226,13 @@ export function CombinedFilterBar({
{selectedFilter?.type === "dateRange" ? (
<div className="flex flex-col sm:flex-row gap-2">
{lang.startsWith("en") ? (
<>
<label className="mt-2">From: </label>
<Input
type="date"
value={fromDate}
onChange={(event) => setFromDate(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addFilter();
}}
className="w-[150px]"
/>
<label className="mt-2">To: </label>
<Input
type="date"
value={toDate}
onChange={(event) => setToDate(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") addFilter();
}}
min={fromDate}
className="w-[150px]"
/>
</>
<DateRangePicker
value={{ from: parseDay(fromDate), to: parseDay(toDate) }}
onChange={(range) => {
setFromDate(formatDay(range.from));
setToDate(formatDay(range.to));
}}
/>
) : (
<div className="flex flex-wrap items-center gap-3">
<div className="relative">