From b4145f49f3ed3d9f5cd09fb47ff2501f6f29cbeb Mon Sep 17 00:00:00 2001 From: dharaneesh-r Date: Sat, 10 Oct 2026 12:18:01 +0530 Subject: [PATCH] updates on the datepciker and the reverse logistics --- src/api/doormile/endpoints.js | 5 + src/api/doormile/queries.js | 10 + .../doormile/ControlXDatePicker.css | 424 +++++++++++++++++ .../doormile/ControlXDatePicker.jsx | 436 ++++++++++++++++++ src/components/doormile/RtoDialogs.jsx | 93 +++- src/lib/clientOnboarding.js | 50 +- src/lib/utils.js | 2 +- src/pages/doormile/deliveries/Deliveries.jsx | 2 +- src/pages/doormile/reports/Profitability.jsx | 13 +- src/pages/doormile/returns/Returns.jsx | 5 +- src/pages/doormile/settings/Settings.jsx | 14 +- tests/lib/reverseLogistics7Days.test.js | 150 ++++++ 12 files changed, 1155 insertions(+), 49 deletions(-) create mode 100644 src/components/doormile/ControlXDatePicker.css create mode 100644 src/components/doormile/ControlXDatePicker.jsx create mode 100644 tests/lib/reverseLogistics7Days.test.js diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index d294301..95ee19f 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -930,6 +930,11 @@ export const RTO_REASONS = [ { value: 'customer_unavailable', label: 'Customer unavailable' }, { value: 'attempts_exhausted', label: 'Delivery attempts exhausted' }, { value: 'damaged', label: 'Damaged in transit' }, + { value: 'defective', label: 'Defective or damaged item' }, + { value: 'wrong_item', label: 'Wrong item delivered' }, + { value: 'quality_issue', label: 'Quality not as expected' }, + { value: 'size_mismatch', label: 'Size or fit mismatch' }, + { value: 'not_needed', label: 'No longer needed' }, { value: 'other', label: 'Other (describe)' }, ]; diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index 071fcff..f04e064 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -1073,11 +1073,21 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { deliveryamount: charge, Quantity: b.parcels?.length || 0, quantity: b.parcels?.length || 0, + deliverycategory: tenant?.deliverycategory || b.deliverycategory || '', + reverselogisticsenabled: tenant?.reverselogisticsenabled, + itemcategory: b.parcels?.[0]?.itemcategory || b.itemcategory || '', + parcels: b.parcels || [], collectionamt: undefined, notes: b.notes || '', deliverytype: customer ? 'B' : 'C', orderdate: b.createdat || b.orderdate || b.updatedat, deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat, + deliveredat: + consignment?.deliveredat || + b.deliveredat || + b.destinations?.find((d) => d.deliveredat)?.deliveredat || + (effectiveDeliveryStatus === 'delivered' ? (consignment?.updatedat || b.updatedat) : undefined), + updatedat: consignment?.updatedat || b.updatedat, reachedat: reached, assigntime: b.updatedat || b.createdat, // The consignment's status WINS when there is one (e.g. Collected_By_Miler -> Picked, diff --git a/src/components/doormile/ControlXDatePicker.css b/src/components/doormile/ControlXDatePicker.css new file mode 100644 index 0000000..a359c90 --- /dev/null +++ b/src/components/doormile/ControlXDatePicker.css @@ -0,0 +1,424 @@ +.controlx-datepicker { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.controlx-datepicker .date-chip { + position: relative; + display: inline-flex; + align-items: center; + gap: 0; + height: 34px; + max-height: 34px; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 8px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; + flex-shrink: 0; + box-sizing: border-box; +} + +.controlx-datepicker .date-chip.is-open { + border-color: #000000; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.14); +} + +.controlx-datepicker .date-chip:hover { + border-color: rgba(0, 0, 0, 0.35); + box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06); +} + +.controlx-datepicker .date-chip:focus-within { + border-color: #000000; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.15); +} + +.controlx-datepicker .date-chip-nav { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 100%; + box-sizing: border-box; + border: 0; + background: rgba(248, 250, 252, 0.7); + color: #64748b; + font-size: 18px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.controlx-datepicker .date-chip-nav:first-child { + border-right: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 7px 0 0 7px; +} + +.controlx-datepicker .date-chip-nav:last-child { + border-left: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0 7px 7px 0; +} + +.controlx-datepicker .date-chip-nav:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.08); + color: #262626; +} + +.controlx-datepicker .date-chip-nav:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.controlx-datepicker .date-chip-nav:focus-visible { + outline: 2px solid rgba(0, 0, 0, 0.5); + outline-offset: -2px; +} + +.controlx-datepicker .date-chip-main { + appearance: none; + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 100%; + padding: 0 10px; + cursor: pointer; + min-width: 0; + background: transparent; + border: 0; + color: inherit; + font: inherit; + text-align: left; + box-sizing: border-box; +} + +.controlx-datepicker .date-chip-main:focus { + outline: none; +} + +.controlx-datepicker .date-chip-main:hover { + background: rgba(0, 0, 0, 0.03); +} + +.controlx-datepicker .date-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); + color: #334155; + font-size: 12px; + flex-shrink: 0; +} + +.controlx-datepicker .date-chip-text { + display: flex; + flex-direction: row; + align-items: center; + line-height: 1; + gap: 6px; + min-width: 0; +} + +.controlx-datepicker .date-chip-today-pill { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 999px; + background: #0f172a; + color: #ffffff; + font-size: 8.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.controlx-datepicker .date-chip-value { + font-size: 12px; + font-weight: 600; + color: #0f172a; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.controlx-datepicker .date-chip-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + color: #94a3b8; + font-size: 16px; + margin-left: 2px; + transition: color 0.18s ease, transform 0.18s ease; +} + +.controlx-datepicker .date-chip-main:hover .date-chip-chevron { + color: #262626; +} + +.controlx-datepicker .date-chip-chevron.is-open { + color: #262626; + transform: rotate(180deg); +} + +/* ── Calendar Popover ─────────────────────────────────────────── */ +.controlx-datepicker .date-cal-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 1000; + width: 320px; + padding: 14px 14px 10px; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 8px; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.18), + 0 4px 12px rgba(15, 23, 42, 0.06); + animation: controlx-date-cal-in 0.18s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; +} + +.controlx-datepicker .date-cal-popover.align-left { + right: auto; + left: 0; +} + +.controlx-datepicker .date-cal-popover::before, +.controlx-datepicker .date-cal-popover::after { + content: ''; + position: absolute; + bottom: 100%; + right: 28px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; +} + +.controlx-datepicker .date-cal-popover.align-left::before, +.controlx-datepicker .date-cal-popover.align-left::after { + right: auto; + left: 28px; +} + +.controlx-datepicker .date-cal-popover::before { + border-bottom: 8px solid rgba(15, 23, 42, 0.08); +} + +.controlx-datepicker .date-cal-popover::after { + border-bottom: 8px solid #ffffff; + margin-bottom: -1px; +} + +@keyframes controlx-date-cal-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.controlx-datepicker .date-cal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.controlx-datepicker .date-cal-title { + flex: 1; + text-align: center; + font-size: 14px; + font-weight: 800; + color: #0f172a; + letter-spacing: 0.01em; +} + +.controlx-datepicker .date-cal-nav { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: rgba(248, 250, 252, 0.9); + color: #475569; + font-size: 18px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.controlx-datepicker .date-cal-nav:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.1); + color: #262626; +} + +.controlx-datepicker .date-cal-nav:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.controlx-datepicker .date-cal-hint { + font-size: 11px; + font-weight: 600; + color: #2563eb; + text-align: center; + margin-bottom: 6px; +} + +.controlx-datepicker .date-cal-weekdays, +.controlx-datepicker .date-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.controlx-datepicker .date-cal-weekdays { + margin-bottom: 4px; +} + +.controlx-datepicker .date-cal-weekday { + text-align: center; + font-size: 10px; + font-weight: 800; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 4px 0; +} + +.controlx-datepicker .date-cal-day { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + border: 0; + border-radius: 6px; + background: transparent; + color: #0f172a; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease, box-shadow 0.18s ease, transform 0.12s ease; +} + +.controlx-datepicker .date-cal-day:hover:not(:disabled):not(.is-selected):not(.is-range-start):not(.is-range-end) { + background: rgba(0, 0, 0, 0.08); + color: #262626; +} + +.controlx-datepicker .date-cal-day.is-other-month { + color: #cbd5e1; + font-weight: 500; +} + +.controlx-datepicker .date-cal-day.is-today { + box-shadow: inset 0 0 0 1.5px rgba(0, 0, 0, 0.55); + color: #262626; + font-weight: 800; +} + +/* Single date selected */ +.controlx-datepicker .date-cal-day.is-selected { + background: linear-gradient(135deg, #000000, #3A3A3A); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); + font-weight: 800; +} + +.controlx-datepicker .date-cal-day.is-selected.is-today { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.controlx-datepicker .date-cal-day.is-selected:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45); +} + +/* Range selection styles */ +.controlx-datepicker .date-cal-day.is-in-range { + background: rgba(15, 23, 42, 0.07); + color: #0f172a; + border-radius: 0; +} + +.controlx-datepicker .date-cal-day.is-range-start { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background: linear-gradient(135deg, #000000, #3A3A3A); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + font-weight: 800; +} + +.controlx-datepicker .date-cal-day.is-range-end { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + background: linear-gradient(135deg, #000000, #3A3A3A); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + font-weight: 800; +} + +.controlx-datepicker .date-cal-day.is-range-start.is-range-end { + border-radius: 6px; +} + +.controlx-datepicker .date-cal-day.is-disabled { + color: #e2e8f0; + cursor: not-allowed; + pointer-events: none; +} + +.controlx-datepicker .date-cal-day:focus-visible { + outline: 2px solid rgba(0, 0, 0, 0.5); + outline-offset: 1px; +} + +.controlx-datepicker .date-cal-presets { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.controlx-datepicker .date-cal-preset { + appearance: none; + flex: 1 1 calc(33.333% - 4px); + padding: 6px 6px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 6px; + background: #ffffff; + color: #475569; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.01em; + text-align: center; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.12s ease; +} + +.controlx-datepicker .date-cal-preset:hover { + background: rgba(0, 0, 0, 0.08); + border-color: rgba(0, 0, 0, 0.35); + color: #262626; + transform: translateY(-1px); +} + +.controlx-datepicker .date-cal-preset:focus-visible { + outline: 2px solid rgba(0, 0, 0, 0.5); + outline-offset: 1px; +} diff --git a/src/components/doormile/ControlXDatePicker.jsx b/src/components/doormile/ControlXDatePicker.jsx new file mode 100644 index 0000000..e5d090d --- /dev/null +++ b/src/components/doormile/ControlXDatePicker.jsx @@ -0,0 +1,436 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { MdCalendarToday, MdChevronLeft, MdChevronRight, MdExpandMore } from 'react-icons/md'; +import './ControlXDatePicker.css'; + +/** + * ControlXDatePicker — The signature Control-X (Dispatch) date selector. + * + * Supports: + * - Single date mode: value = 'YYYY-MM-DD' + * - Range mode: value = { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } + * - 1-click scrubbing (< and >) + * - Sleek trigger chip with calendar badge, "Today" pill, and formatted date(s) + * - Month navigation with future dates disabled + * - Range drag / preview highlighting + * - Quick presets (Today, Yesterday, Last 7d, Last 30d, This month) + */ +export default function ControlXDatePicker({ + value, + onChange, + range: forceRange, + maxDate, + className = '', + align = 'right', +}) { + const [open, setOpen] = useState(false); + const [pendingFrom, setPendingFrom] = useState(null); + const [hoverDate, setHoverDate] = useState(null); + const containerRef = useRef(null); + + 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]); + + const ceilingDate = useMemo(() => { + if (maxDate && dayjs(maxDate).isValid()) { + return dayjs(maxDate).startOf('day'); + } + return today; + }, [maxDate, today]); + + // Determine whether this picker is in range mode + const isRange = useMemo(() => { + if (typeof forceRange === 'boolean') return forceRange; + return typeof value === 'object' && value !== null; + }, [forceRange, value]); + + // Normalize parsed values + const parsedRange = useMemo(() => { + if (isRange) { + const from = value?.from || todayStr; + const to = value?.to || todayStr; + return { from, to }; + } + const single = typeof value === 'string' && value ? value : todayStr; + return { from: single, to: single }; + }, [isRange, value, todayStr]); + + const isToday = useMemo(() => { + if (isRange) { + return parsedRange.from === todayStr && parsedRange.to === todayStr; + } + return parsedRange.from === todayStr; + }, [isRange, parsedRange, todayStr]); + + const [calViewMonth, setCalViewMonth] = useState(() => { + const initial = dayjs(parsedRange.to || parsedRange.from); + return initial.isValid() ? initial.startOf('month') : today.startOf('month'); + }); + + // Close calendar popover on click outside or Escape + useEffect(() => { + if (!open) return undefined; + const handleClickOutside = (e) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(e.target)) { + setOpen(false); + setPendingFrom(null); + setHoverDate(null); + } + }; + const handleKey = (e) => { + if (e.key === 'Escape') { + setOpen(false); + setPendingFrom(null); + setHoverDate(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKey); + }; + }, [open]); + + // Sync visible month when popover opens + useEffect(() => { + if (open) { + const ref = dayjs(parsedRange.to || parsedRange.from); + setCalViewMonth(ref.isValid() ? ref.startOf('month') : today.startOf('month')); + setPendingFrom(null); + setHoverDate(null); + } + }, [open, parsedRange.from, parsedRange.to]); + + const commitSingle = (nextDayjs) => { + if (!nextDayjs || !nextDayjs.isValid()) return; + if (nextDayjs.isAfter(ceilingDate, 'day')) return; + const str = nextDayjs.format('YYYY-MM-DD'); + if (onChange) onChange(str); + setOpen(false); + }; + + const commitRange = (fromStr, toStr) => { + const [start, end] = fromStr <= toStr ? [fromStr, toStr] : [toStr, fromStr]; + if (onChange) onChange({ from: start, to: end }); + setPendingFrom(null); + setHoverDate(null); + setOpen(false); + }; + + // Day scrubbing (< and >) + const goPrev = (e) => { + e.stopPropagation(); + if (isRange) { + const startD = dayjs(parsedRange.from); + const endD = dayjs(parsedRange.to); + const span = Math.max(1, endD.diff(startD, 'day') + 1); + const newFrom = startD.subtract(span, 'day').format('YYYY-MM-DD'); + const newTo = endD.subtract(span, 'day').format('YYYY-MM-DD'); + if (onChange) onChange({ from: newFrom, to: newTo }); + } else { + const base = dayjs(parsedRange.from); + commitSingle(base.subtract(1, 'day')); + } + }; + + const goNext = (e) => { + e.stopPropagation(); + if (isRange) { + const startD = dayjs(parsedRange.from); + const endD = dayjs(parsedRange.to); + const span = Math.max(1, endD.diff(startD, 'day') + 1); + let newEnd = endD.add(span, 'day'); + let newStart = startD.add(span, 'day'); + if (newEnd.isAfter(ceilingDate, 'day')) { + newEnd = ceilingDate; + newStart = ceilingDate.subtract(span - 1, 'day'); + } + if (onChange) onChange({ from: newStart.format('YYYY-MM-DD'), to: newEnd.format('YYYY-MM-DD') }); + } else { + const base = dayjs(parsedRange.from); + if (base.isSame(ceilingDate, 'day') || base.isAfter(ceilingDate, 'day')) return; + commitSingle(base.add(1, 'day')); + } + }; + + const canGoNext = useMemo(() => { + if (isRange) { + const endD = dayjs(parsedRange.to); + return endD.isBefore(ceilingDate, 'day'); + } + const base = dayjs(parsedRange.from); + return base.isBefore(ceilingDate, 'day'); + }, [isRange, parsedRange, ceilingDate]); + + // Month navigation + const prevMonth = () => setCalViewMonth((m) => m.subtract(1, 'month')); + const nextMonth = () => { + const candidate = calViewMonth.add(1, 'month'); + if (candidate.startOf('month').isAfter(ceilingDate, 'month')) return; + setCalViewMonth(candidate); + }; + const canGoNextMonth = !calViewMonth.add(1, 'month').startOf('month').isAfter(ceilingDate, 'month'); + + // Build fixed 6x7 grid (42 cells) + const monthStart = calViewMonth.startOf('month'); + const gridStart = monthStart.subtract(monthStart.day(), 'day'); + const cells = useMemo( + () => Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day')), + [gridStart] + ); + + const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + // Current active range (preview while pending, or committed) + const activeRange = useMemo(() => { + if (!isRange) return null; + if (pendingFrom) { + const end = hoverDate || pendingFrom; + return pendingFrom <= end ? { from: pendingFrom, to: end } : { from: end, to: pendingFrom }; + } + return { from: parsedRange.from, to: parsedRange.to }; + }, [isRange, pendingFrom, hoverDate, parsedRange]); + + const handleDayClick = (dayStr) => { + if (isRange) { + if (!pendingFrom) { + setPendingFrom(dayStr); + setHoverDate(dayStr); + } else { + commitRange(pendingFrom, dayStr); + } + } else { + commitSingle(dayjs(dayStr)); + } + }; + + // Label formatting on trigger chip + const triggerLabel = useMemo(() => { + if (isRange) { + const f = dayjs(parsedRange.from); + const t = dayjs(parsedRange.to); + if (!f.isValid() || !t.isValid()) return '—'; + if (parsedRange.from === parsedRange.to) { + return f.format('ddd, MMM D, YYYY'); + } + return `${f.format('MMM D')} → ${t.format('MMM D, YYYY')}`; + } + const d = dayjs(parsedRange.from); + return d.isValid() ? d.format('ddd, MMM D, YYYY') : '—'; + }, [isRange, parsedRange]); + + return ( +
+
+ + + + + + + {open && ( +
+
+ +
{calViewMonth.format('MMMM YYYY')}
+ +
+ + {isRange && pendingFrom && ( +
Click a date to finish range
+ )} + +
+ {WEEKDAYS.map((w) => ( +
+ {w} +
+ ))} +
+ +
+ {cells.map((d) => { + const dayStr = d.format('YYYY-MM-DD'); + const inMonth = d.month() === calViewMonth.month(); + const isTodayCell = dayStr === todayStr; + const disabled = d.isAfter(ceilingDate, 'day'); + + let isStart = false; + let isEnd = false; + let inRange = false; + let isSelectedSingle = false; + + if (isRange && activeRange) { + isStart = dayStr === activeRange.from; + isEnd = dayStr === activeRange.to; + inRange = dayStr >= activeRange.from && dayStr <= activeRange.to; + } else if (!isRange) { + isSelectedSingle = dayStr === parsedRange.from; + } + + const cls = [ + 'date-cal-day', + !inMonth && 'is-other-month', + isSelectedSingle && 'is-selected', + isStart && 'is-range-start', + isEnd && 'is-range-end', + inRange && !isStart && !isEnd && 'is-in-range', + isTodayCell && 'is-today', + disabled && 'is-disabled', + ] + .filter(Boolean) + .join(' '); + + return ( + + ); + })} +
+ +
+ + + + {isRange ? ( + <> + + + + ) : null} +
+
+ )} +
+
+ ); +} + +ControlXDatePicker.propTypes = { + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.shape({ + from: PropTypes.string, + to: PropTypes.string, + }), + ]), + onChange: PropTypes.func.isRequired, + range: PropTypes.bool, + maxDate: PropTypes.string, + className: PropTypes.string, + align: PropTypes.oneOf(['left', 'right']), +}; diff --git a/src/components/doormile/RtoDialogs.jsx b/src/components/doormile/RtoDialogs.jsx index 84a7db5..272e5ab 100644 --- a/src/components/doormile/RtoDialogs.jsx +++ b/src/components/doormile/RtoDialogs.jsx @@ -1,31 +1,89 @@ import React, { useEffect, useState } from 'react'; +import dayjs from 'dayjs'; import { PackageCheck, RotateCcw, Undo2 } from 'lucide-react'; import { Alert, Button, Field, Modal, Textarea } from '@/components/ds'; import { inputVariants } from '@/components/ui/input'; import { RTO_REASONS } from '@/api/doormile/endpoints'; import { useCancelRto, useCompleteRto, useInitiateRto } from '@/lib/doormileHooks'; +import { isPerishableCategory } from '@/lib/clientOnboarding'; /** * Reverse logistics (RTO) dialogs, shared by Deliveries, Returns and * Exceptions. Plan: docs/reverse-logistics-plan.md. * - * A return can start while the parcel is in a rider's hands or at a base; the - * backend refuses anything else (delivered, cancelled, already returning) and - * its message is shown in the dialog. Every action needs the CONSIGNMENT id — - * a booking that has not been picked up has no parcel to return yet. + * A return can start: + * 1. While the parcel is in transit or at a base (in-transit RTO). + * 2. Within a 7-day post-delivery window (Amazon-style customer return). + * After 7 days from delivery, the return capability is automatically expired/hidden. + * + * Food items, meat items, and fresh produce CANNOT be returned. + * Products like steel, electronics, and clothing CAN be returned. */ -/** Delivery-list statuses (queries.js) a return can be started from. */ +/** Delivery-list statuses (queries.js) an in-transit return can be started from. */ export const RTO_STARTABLE = ['picked', 'active', 'skipped']; -/** What a row may do, from its delivery-list status. */ +/** Amazon-style return window in days for delivered parcels */ +export const RETURN_WINDOW_DAYS = 7; + +/** Returns the age of delivery in days (fractional) */ +export function getDeliveryAgeDays(deliveredAt) { + if (!deliveredAt) return null; + const d = dayjs(deliveredAt); + if (!d.isValid()) return null; + const diffHours = dayjs().diff(d, 'hour', true); + return Math.max(0, diffHours / 24); +} + +/** What a row may do, from its delivery-list status and category. */ export function rtoActionsFor(row) { const status = String(row?.orderstatus || '').toLowerCase(); const hasParcel = row?.consignmentid != null && row?.consignmentid !== ''; - return { - canStart: hasParcel && RTO_STARTABLE.includes(status), - canResolve: hasParcel && status === 'rto', - }; + if (!hasParcel) { + return { canStart: false, canResolve: false, windowExpired: false, isPostDelivery: false }; + } + + // 1. Food items, meat items, and fresh produce do NOT have return options. + // Durable products like steel, electronics, clothing, etc. DO have return options. + const category = row?.deliverycategory || row?.itemcategory || row?.tenantcategory; + const isPerishable = + isPerishableCategory(category) || + row?.reverselogisticsenabled === false || + (Array.isArray(row?.parcels) && + row.parcels.some((p) => isPerishableCategory(p?.itemcategory || p?.itemdescription))); + + if (isPerishable) { + return { canStart: false, canResolve: false, windowExpired: false, isPostDelivery: false, isPerishable: true }; + } + + // Active / in-transit RTO (for returnable goods) + if (RTO_STARTABLE.includes(status)) { + return { canStart: true, canResolve: false, windowExpired: false, isPostDelivery: false }; + } + + // Active return being processed + if (status === 'rto') { + return { canStart: false, canResolve: true, windowExpired: false, isPostDelivery: false }; + } + + // Delivered: Amazon 7-day post-delivery reverse logistics window + if (status === 'delivered') { + const deliveryTimestamp = row?.deliveredat || row?.deliverydate || row?.updatedat || row?.assigntime; + const ageDays = getDeliveryAgeDays(deliveryTimestamp); + // If delivery timestamp is available, enforce age <= 7 days (168 hours). + // If ageDays > 7 days, the window is expired -> hide the return icon! + const isEligible = ageDays != null ? ageDays <= RETURN_WINDOW_DAYS : true; + const daysLeft = ageDays != null ? Math.max(0, Math.ceil(RETURN_WINDOW_DAYS - ageDays)) : RETURN_WINDOW_DAYS; + return { + canStart: isEligible, + canResolve: false, + windowExpired: !isEligible, + daysRemaining: daysLeft, + isPostDelivery: true, + }; + } + + return { canStart: false, canResolve: false, windowExpired: false, isPostDelivery: false }; } const errorText = (err, fallback) => err?.response?.data?.message || err?.message || fallback; @@ -34,13 +92,16 @@ const errorText = (err, fallback) => err?.response?.data?.message || err?.messag const subtitleFor = (row) => row ? row.label || `Order ${row.orderid ?? row.trackingno ?? row.consignmentid}` : undefined; -/** Start a return to sender. `row` needs `consignmentid` and an order label. */ +/** Start a return to sender or customer return. `row` needs `consignmentid` and an order label. */ export function StartRtoModal({ row, onClose }) { const start = useInitiateRto(); const [reason, setReason] = useState(''); const [note, setNote] = useState(''); const [error, setError] = useState(''); + const isPostDelivery = String(row?.orderstatus || '').toLowerCase() === 'delivered'; + const actions = rtoActionsFor(row); + // `row.defaultReason` pre-selects a reason (e.g. from an exception's type). useEffect(() => { setReason(row?.defaultReason || ''); @@ -70,17 +131,17 @@ export function StartRtoModal({ row, onClose }) { !open && onClose()} - title="Return to sender" + title={isPostDelivery ? "Customer return" : "Return to sender"} description={subtitleFor(row)} icon={Undo2} busy={start.isPending} footer={ <> } @@ -91,7 +152,9 @@ export function StartRtoModal({ row, onClose }) { )}

- The parcel goes back to the sender's pickup point instead of being delivered. The rider is notified. + {isPostDelivery + ? `Initiate a customer return for this delivered order (within 7-day return window: ${actions.daysRemaining} days remaining). The parcel will be scheduled for return.` + : "The parcel goes back to the sender's pickup point instead of being delivered. The rider is notified."}

e.target.value && setReportDate(e.target.value)} - aria-label="Report date" - className="w-44 bg-white shadow-xs" + onChange={setReportDate} + maxDate={dayjs().format('YYYY-MM-DD')} /> )} diff --git a/tests/lib/reverseLogistics7Days.test.js b/tests/lib/reverseLogistics7Days.test.js new file mode 100644 index 0000000..ee6d152 --- /dev/null +++ b/tests/lib/reverseLogistics7Days.test.js @@ -0,0 +1,150 @@ +import React from 'react'; +import dayjs from 'dayjs'; + +jest.mock('lucide-react', () => + new Proxy({}, { get: (_t, prop) => (prop === '__esModule' ? true : (props) => ) }) +); + +import { + rtoActionsFor, + RETURN_WINDOW_DAYS, + getDeliveryAgeDays, + RTO_STARTABLE, +} from '@/components/doormile/RtoDialogs'; +import { RTO_REASONS } from '@/api/doormile/endpoints'; + +describe('7-Day Amazon-Style Reverse Logistics', () => { + it('defines RETURN_WINDOW_DAYS as 7', () => { + expect(RETURN_WINDOW_DAYS).toBe(7); + }); + + it('allows in-transit orders to initiate RTO', () => { + RTO_STARTABLE.forEach((status) => { + const actions = rtoActionsFor({ consignmentid: 123, orderstatus: status }); + expect(actions.canStart).toBe(true); + expect(actions.isPostDelivery).toBe(false); + expect(actions.windowExpired).toBe(false); + }); + }); + + it('allows orders delivered within 7 days to initiate customer return', () => { + // Delivered 2 days ago + const twoDaysAgo = dayjs().subtract(2, 'day').toISOString(); + const actions = rtoActionsFor({ + consignmentid: 101, + orderstatus: 'delivered', + deliveredat: twoDaysAgo, + }); + + expect(actions.canStart).toBe(true); + expect(actions.isPostDelivery).toBe(true); + expect(actions.windowExpired).toBe(false); + expect(actions.daysRemaining).toBe(5); + }); + + it('allows orders delivered 6 days ago (near boundary) to initiate return', () => { + const sixDaysAgo = dayjs().subtract(6, 'day').toISOString(); + const actions = rtoActionsFor({ + consignmentid: 102, + orderstatus: 'delivered', + deliveredat: sixDaysAgo, + }); + + expect(actions.canStart).toBe(true); + expect(actions.isPostDelivery).toBe(true); + expect(actions.windowExpired).toBe(false); + expect(actions.daysRemaining).toBe(1); + }); + + it('HIDES/DISABLES return for orders delivered MORE than 7 days ago', () => { + // Delivered 8 days ago + const eightDaysAgo = dayjs().subtract(8, 'day').toISOString(); + const actions = rtoActionsFor({ + consignmentid: 103, + orderstatus: 'delivered', + deliveredat: eightDaysAgo, + }); + + // canStart is false -> button is hidden in Deliveries table + expect(actions.canStart).toBe(false); + expect(actions.isPostDelivery).toBe(true); + expect(actions.windowExpired).toBe(true); + expect(actions.daysRemaining).toBe(0); + }); + + it('falls back to updatedat if deliveredat is missing on delivered order', () => { + const threeDaysAgo = dayjs().subtract(3, 'day').toISOString(); + const actions = rtoActionsFor({ + consignmentid: 104, + orderstatus: 'delivered', + updatedat: threeDaysAgo, + }); + + expect(actions.canStart).toBe(true); + expect(actions.daysRemaining).toBe(4); + }); + + it('rejects cancelled or invalid rows', () => { + expect(rtoActionsFor({ consignmentid: 105, orderstatus: 'cancelled' }).canStart).toBe(false); + expect(rtoActionsFor({ consignmentid: null, orderstatus: 'delivered' }).canStart).toBe(false); + expect(rtoActionsFor(null).canStart).toBe(false); + }); + + it('includes post-delivery customer return reasons', () => { + const values = RTO_REASONS.map((r) => r.value); + expect(values).toContain('wrong_item'); + expect(values).toContain('defective'); + expect(values).toContain('quality_issue'); + expect(values).toContain('size_mismatch'); + expect(values).toContain('not_needed'); + }); + + it('BLOCKS returns for Food items (canStart is false, button is hidden)', () => { + const foodRow = { + consignmentid: 201, + orderstatus: 'delivered', + deliverycategory: 'Food', + deliveredat: dayjs().subtract(1, 'day').toISOString(), + }; + const actions = rtoActionsFor(foodRow); + expect(actions.canStart).toBe(false); + expect(actions.isPerishable).toBe(true); + }); + + it('BLOCKS returns for Meat items (canStart is false, button is hidden)', () => { + const meatRow = { + consignmentid: 202, + orderstatus: 'delivered', + itemcategory: 'Raw Meat', + deliveredat: dayjs().subtract(1, 'day').toISOString(), + }; + const actions = rtoActionsFor(meatRow); + expect(actions.canStart).toBe(false); + expect(actions.isPerishable).toBe(true); + }); + + it('BLOCKS returns for Fresh Produce items (canStart is false, button is hidden)', () => { + const freshRow = { + consignmentid: 203, + orderstatus: 'delivered', + parcels: [{ itemcategory: 'Fresh Vegetables' }], + deliveredat: dayjs().subtract(1, 'day').toISOString(), + }; + const actions = rtoActionsFor(freshRow); + expect(actions.canStart).toBe(false); + expect(actions.isPerishable).toBe(true); + }); + + it('ALLOWS returns for Steel and durable goods within 7 days', () => { + const steelRow = { + consignmentid: 204, + orderstatus: 'delivered', + deliverycategory: 'Steel', + deliveredat: dayjs().subtract(2, 'day').toISOString(), + }; + const actions = rtoActionsFor(steelRow); + expect(actions.canStart).toBe(true); + expect(actions.isPerishable).toBeUndefined(); + expect(actions.daysRemaining).toBe(5); + }); +});