diff --git a/src/components/ds/DatePicker.jsx b/src/components/ds/DatePicker.jsx new file mode 100644 index 0000000..42fbb22 --- /dev/null +++ b/src/components/ds/DatePicker.jsx @@ -0,0 +1,370 @@ +import React, { useState, useMemo, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { Calendar as CalendarIcon, ChevronLeft, ChevronRight, ChevronDown } from 'lucide-react'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +/** + * DatePicker — KROW design system date selector. + * + * Supports: + * - Single date ('YYYY-MM-DD') or Range ({ from, to }) or 'all' + * - Default "Today" quick preset + * - 1-click day scrubbing via prev/next steppers (< and >) + * - Interactive month grid with Today indicator and range highlights + * - Quick presets: Today, Yesterday, Last 7 Days, This Month, All Dates + */ +export function DatePicker({ + value, + onChange, + allowAll = true, + showSteppers = true, + className = '', + placeholder = 'Select date', + disabled = false, + align = 'end', +}) { + const [open, setOpen] = useState(false); + + const today = useMemo(() => dayjs().startOf('day'), []); + const todayStr = useMemo(() => today.format('YYYY-MM-DD'), [today]); + const yesterdayStr = useMemo(() => today.subtract(1, 'day').format('YYYY-MM-DD'), [today]); + + // Normalize value to { from, to, isAll, isSingle } + const parsed = useMemo(() => { + if (!value || value === 'all') { + return { from: '', to: '', isAll: true, isSingle: false, isToday: false, isYesterday: false }; + } + if (typeof value === 'string') { + const isToday = value === todayStr; + const isYesterday = value === yesterdayStr; + return { from: value, to: value, isAll: false, isSingle: true, isToday, isYesterday }; + } + if (typeof value === 'object') { + const from = value.from || ''; + const to = value.to || ''; + if (!from && !to) { + return { from: '', to: '', isAll: true, isSingle: false, isToday: false, isYesterday: false }; + } + const isSingle = from === to; + const isToday = isSingle && from === todayStr; + const isYesterday = isSingle && from === yesterdayStr; + return { from, to, isAll: false, isSingle, isToday, isYesterday }; + } + return { from: '', to: '', isAll: false, isSingle: false, isToday: false, isYesterday: false }; + }, [value, todayStr, yesterdayStr]); + + // Current calendar viewing month + const [viewMonth, setViewMonth] = useState(() => { + if (parsed.from && dayjs(parsed.from).isValid()) { + return dayjs(parsed.from).startOf('month'); + } + return today.startOf('month'); + }); + + // Keep viewMonth synced when value changes and popover opens + useEffect(() => { + if (open) { + if (parsed.from && dayjs(parsed.from).isValid()) { + setViewMonth(dayjs(parsed.from).startOf('month')); + } else { + setViewMonth(today.startOf('month')); + } + } + }, [open, parsed.from, today]); + + // Label text for trigger chip + const displayLabel = useMemo(() => { + if (parsed.isAll) return 'All Dates'; + if (parsed.isToday) return 'Today'; + if (parsed.isYesterday) return 'Yesterday'; + if (parsed.isSingle && parsed.from) { + const d = dayjs(parsed.from); + return d.isValid() ? d.format('DD MMM YYYY') : parsed.from; + } + if (parsed.from && parsed.to) { + const d1 = dayjs(parsed.from); + const d2 = dayjs(parsed.to); + if (d1.isValid() && d2.isValid()) { + return `${d1.format('DD MMM')} → ${d2.format('DD MMM YYYY')}`; + } + return `${parsed.from} → ${parsed.to}`; + } + if (parsed.from) { + const d = dayjs(parsed.from); + return d.isValid() ? d.format('DD MMM YYYY') : parsed.from; + } + return placeholder; + }, [parsed, placeholder]); + + // Commit a date or preset + const commit = (nextVal) => { + if (onChange) { + onChange(nextVal); + } + setOpen(false); + }; + + // Day scrubbing (< and >) + const handlePrevDay = (e) => { + e.stopPropagation(); + if (disabled) return; + const baseDate = parsed.from && dayjs(parsed.from).isValid() ? dayjs(parsed.from) : today; + const prev = baseDate.subtract(1, 'day').format('YYYY-MM-DD'); + if (typeof value === 'object' && !parsed.isSingle && value.to) { + commit({ from: prev, to: prev }); + } else { + commit(prev); + } + }; + + const handleNextDay = (e) => { + e.stopPropagation(); + if (disabled) return; + const baseDate = parsed.from && dayjs(parsed.from).isValid() ? dayjs(parsed.from) : today; + const next = baseDate.add(1, 'day').format('YYYY-MM-DD'); + if (typeof value === 'object' && !parsed.isSingle && value.to) { + commit({ from: next, to: next }); + } else { + commit(next); + } + }; + + // 6x7 Grid generation for viewMonth + const monthStart = viewMonth.startOf('month'); + const gridStart = monthStart.subtract(monthStart.day(), 'day'); + const cells = useMemo(() => { + return Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day')); + }, [gridStart]); + + const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']; + + const isSelected = (day) => { + if (parsed.isAll) return false; + const s = day.format('YYYY-MM-DD'); + if (parsed.from && parsed.to) { + return s >= parsed.from && s <= parsed.to; + } + return s === parsed.from; + }; + + const isRangeEndpoint = (day) => { + if (parsed.isAll) return false; + const s = day.format('YYYY-MM-DD'); + return s === parsed.from || s === parsed.to; + }; + + return ( + +
+ {showSteppers && ( + + )} + + + + + + {showSteppers && ( + + )} +
+ + + {/* Month Navigation */} +
+ + + {viewMonth.format('MMMM YYYY')} + + +
+ + {/* Weekdays */} +
+ {WEEKDAYS.map((w) => ( +
{w}
+ ))} +
+ + {/* Calendar Grid */} +
+ {cells.map((d) => { + const dateStr = d.format('YYYY-MM-DD'); + const inMonth = d.month() === viewMonth.month(); + const selected = isSelected(d); + const isTodayCell = dateStr === todayStr; + const endpoint = isRangeEndpoint(d); + + return ( + + ); + })} +
+ + {/* Quick Presets */} +
+ + + + + {allowAll && ( + + )} +
+
+
+ ); +} + +DatePicker.propTypes = { + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + from: PropTypes.string, + to: PropTypes.string, + }), + ]), + onChange: PropTypes.func, + allowAll: PropTypes.bool, + showSteppers: PropTypes.bool, + className: PropTypes.string, + placeholder: PropTypes.string, + disabled: PropTypes.bool, + align: PropTypes.oneOf(['start', 'center', 'end']), +}; + +export default DatePicker; diff --git a/src/components/ds/index.js b/src/components/ds/index.js index 8e5f2b5..156e969 100644 --- a/src/components/ds/index.js +++ b/src/components/ds/index.js @@ -29,6 +29,7 @@ export { Input, inputVariants } from '@/components/ui/input'; export { Textarea } from '@/components/ui/textarea'; export { Label } from '@/components/ui/label'; export { SearchInput } from './SearchInput'; +export { DatePicker } from './DatePicker'; export { MultiSelect } from './MultiSelect'; export { Toggle, SegmentedToggle } from './Toggle'; export { Checkbox } from '@/components/ui/checkbox'; diff --git a/src/pages/doormile/deliveries/Deliveries.jsx b/src/pages/doormile/deliveries/Deliveries.jsx index 918fc4a..3e821c1 100644 --- a/src/pages/doormile/deliveries/Deliveries.jsx +++ b/src/pages/doormile/deliveries/Deliveries.jsx @@ -1,7 +1,8 @@ import React, { useEffect, useMemo, useState } from 'react'; +import dayjs from 'dayjs'; import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react'; import { - Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input, + Alert, Button, DataTable, DatePicker, Drawer, EmptyState, Field, IconButton, Input, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector, } from '@/components/ds'; @@ -62,7 +63,8 @@ const SETTABLE_STATUSES = [ ]; export default function Deliveries() { - const [range, setRange] = useState({ from: '', to: '' }); + const todayStr = dayjs().format('YYYY-MM-DD'); + const [selectedDate, setSelectedDate] = useState(todayStr); const [tab, setTab] = useState('all'); const [batch, setBatch] = useState('all'); const [search, setSearch] = useState(''); @@ -80,11 +82,17 @@ export default function Deliveries() { const [cancelRow, setCancelRow] = useState(null); const [cancelReason, setCancelReason] = useState(''); + const dateParams = useMemo(() => { + if (!selectedDate || selectedDate === 'all') return { from: '', to: '' }; + if (typeof selectedDate === 'string') return { from: selectedDate, to: selectedDate }; + return { from: selectedDate.from || '', to: selectedDate.to || '' }; + }, [selectedDate]); + const { data, isLoading, isFetching } = useDeliveries({ page, pageSize: 100, - from: range.from, - to: range.to, + from: dateParams.from, + to: dateParams.to, }); const { data: counts } = useDeliveryCounts(); const { data: riders = [] } = useRiderOptions(); @@ -195,7 +203,7 @@ export default function Deliveries() { means anything. */ useEffect(() => { setPage(1); - }, [tab, batch, tenantId, riderId, debouncedSearch, range.from, range.to]); + }, [tab, batch, tenantId, riderId, debouncedSearch, dateParams.from, dateParams.to]); const columns = useMemo( () => [ @@ -375,30 +383,23 @@ export default function Deliveries() { - setRange((current) => ({ ...current, from: e.target.value }))} - aria-label="From date" - className="w-40" - /> - setRange((current) => ({ ...current, to: e.target.value }))} - aria-label="To date" - className="w-40" - /> - + } /> @@ -418,7 +419,7 @@ export default function Deliveries() { actions={