updates on the datepciker and the reverse logistics

This commit is contained in:
2026-10-10 12:18:01 +05:30
parent 5faf0e1deb
commit b4145f49f3
12 changed files with 1155 additions and 49 deletions

View File

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

View File

@@ -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,

View File

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

View File

@@ -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 (
<div className={`controlx-datepicker ${className}`} ref={containerRef}>
<div className={`date-chip${isToday ? ' is-today' : ''}${open ? ' is-open' : ''}`}>
<button
type="button"
className="date-chip-nav"
onClick={goPrev}
aria-label="Previous period"
title="Previous period"
>
<MdChevronLeft />
</button>
<button
type="button"
className="date-chip-main"
onClick={() => setOpen((o) => !o)}
aria-haspopup="dialog"
aria-expanded={open}
>
<span className="date-chip-icon" aria-hidden="true">
<MdCalendarToday />
</span>
<span className="date-chip-text">
{isToday && <span className="date-chip-today-pill">Today</span>}
<span className="date-chip-value">{triggerLabel}</span>
</span>
<span className={`date-chip-chevron${open ? ' is-open' : ''}`} aria-hidden="true">
<MdExpandMore />
</span>
</button>
<button
type="button"
className="date-chip-nav"
onClick={goNext}
disabled={!canGoNext}
aria-label="Next period"
title={!canGoNext ? 'Latest available period' : 'Next period'}
>
<MdChevronRight />
</button>
{open && (
<div className={`date-cal-popover${align === 'left' ? ' align-left' : ''}`} role="dialog" aria-label="Pick a date">
<div className="date-cal-header">
<button
type="button"
className="date-cal-nav"
onClick={prevMonth}
aria-label="Previous month"
>
<MdChevronLeft />
</button>
<div className="date-cal-title">{calViewMonth.format('MMMM YYYY')}</div>
<button
type="button"
className="date-cal-nav"
onClick={nextMonth}
disabled={!canGoNextMonth}
aria-label="Next month"
>
<MdChevronRight />
</button>
</div>
{isRange && pendingFrom && (
<div className="date-cal-hint">Click a date to finish range</div>
)}
<div className="date-cal-weekdays">
{WEEKDAYS.map((w) => (
<div key={w} className="date-cal-weekday">
{w}
</div>
))}
</div>
<div className="date-cal-grid">
{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 (
<button
key={dayStr}
type="button"
className={cls}
disabled={disabled}
onClick={() => handleDayClick(dayStr)}
onMouseEnter={() => {
if (isRange && pendingFrom && !disabled) {
setHoverDate(dayStr);
}
}}
aria-current={isTodayCell ? 'date' : undefined}
aria-pressed={isSelectedSingle || isStart || isEnd}
>
{d.date()}
</button>
);
})}
</div>
<div className="date-cal-presets">
<button
type="button"
className="date-cal-preset"
onClick={() => {
if (isRange) commitRange(todayStr, todayStr);
else commitSingle(today);
}}
disabled={today.isAfter(ceilingDate, 'day')}
>
Today
</button>
<button
type="button"
className="date-cal-preset"
onClick={() => {
if (isRange) commitRange(yesterdayStr, yesterdayStr);
else commitSingle(today.subtract(1, 'day'));
}}
>
Yesterday
</button>
<button
type="button"
className="date-cal-preset"
onClick={() => {
const past7 = today.subtract(6, 'day').format('YYYY-MM-DD');
if (isRange) commitRange(past7, todayStr);
else commitSingle(today.subtract(7, 'day'));
}}
>
Last 7d
</button>
{isRange ? (
<>
<button
type="button"
className="date-cal-preset"
onClick={() => {
const past30 = today.subtract(29, 'day').format('YYYY-MM-DD');
commitRange(past30, todayStr);
}}
>
Last 30d
</button>
<button
type="button"
className="date-cal-preset"
onClick={() => {
const monthStartStr = today.startOf('month').format('YYYY-MM-DD');
commitRange(monthStartStr, todayStr);
}}
>
This month
</button>
</>
) : null}
</div>
</div>
)}
</div>
</div>
);
}
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']),
};

View File

@@ -1,46 +1,107 @@
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 !== '';
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: hasParcel && RTO_STARTABLE.includes(status),
canResolve: hasParcel && status === 'rto',
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;
/** The dialog subtitle: a caller's own `label`, else "Order <no>". */
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 }) {
<Modal
open={Boolean(row)}
onOpenChange={(open) => !open && onClose()}
title="Return to sender"
title={isPostDelivery ? "Customer return" : "Return to sender"}
description={subtitleFor(row)}
icon={Undo2}
busy={start.isPending}
footer={
<>
<Button variant="ghost" onClick={onClose} disabled={start.isPending}>
Keep delivering
{isPostDelivery ? "Keep order" : "Keep delivering"}
</Button>
<Button variant="destructive" onClick={submit} loading={start.isPending} disabled={!canSubmit}>
Start return
{isPostDelivery ? "Start customer return" : "Start return"}
</Button>
</>
}
@@ -91,7 +152,9 @@ export function StartRtoModal({ row, onClose }) {
</Alert>
)}
<p className="mb-3 text-body-sm text-ink-2">
The parcel goes back to the sender&apos;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."}
</p>
<Field label="Reason" required>
<select

View File

@@ -45,21 +45,49 @@ export function generateClientPassword(length = 14) {
*/
export const DELIVERY_CATEGORIES = [
'General', 'Documents', 'Electronics', 'Clothing',
'Fragile', 'Medical', 'Automotive', 'Food'
'Fragile', 'Medical', 'Automotive', 'Steel', 'Food', 'Meat', 'FreshProduce'
];
/**
* Whether a return journey makes sense for what this client ships.
*
* Food is the exception: a meal that comes back is waste, not inventory.
* Mirrors constants.ReverseLogisticsAllowed, which ENFORCES this in
* InitiateConsignmentRTO — this copy only decides what the form shows, and
* hiding a control is a courtesy, not a rule.
*
* Unknown or empty allows returns, matching the server: the field is new, and
* new information must not withdraw a capability existing clients already use.
* Non-returnable categories (Food, Meat, Fresh Produce).
* Food and fresh items that come back produce waste and cannot be restocked.
* Durable products like Steel, Electronics, Clothing, etc. have standard return paths.
*/
export const reverseLogisticsAllowed = (category) => category !== 'Food';
export const NON_RETURNABLE_CATEGORIES = [
'food',
'meat',
'raw meat',
'fresh produce',
'freshproduce',
'fresh',
'perishable',
'groceries',
'fish',
'seafood',
'poultry',
'dairy',
'vegetables',
'fruits',
];
export const isPerishableCategory = (category) => {
if (!category) return false;
const norm = String(category).trim().toLowerCase();
return (
NON_RETURNABLE_CATEGORIES.includes(norm) ||
norm.includes('food') ||
norm.includes('meat') ||
norm.includes('fresh') ||
norm.includes('perish')
);
};
/**
* Whether reverse logistics (return options) are available for a given product or client category.
* Food items, Meat items, and Fresh produce CANNOT be returned.
* Products like Steel, Electronics, Clothing, etc. CAN be returned.
*/
export const reverseLogisticsAllowed = (category) => !isPerishableCategory(category);
export function validateOnboarding(form) {
const errors = {};

View File

@@ -31,4 +31,4 @@ export function cn(...inputs) {
return twMerge(clsx(inputs))
}
export const isIframe = window.self !== window.top;
export const isIframe = typeof window !== 'undefined' && window.self !== window.top;

View File

@@ -365,7 +365,7 @@ export default function Deliveries() {
{!isClient && rtoActionsFor(row).canStart ? (
<IconButton
label="Return to sender"
label={row.orderstatus === 'delivered' ? `Customer return (${rtoActionsFor(row).daysRemaining}d left)` : "Return to sender"}
icon={Undo2}
variant="ghost"
size="sm"

View File

@@ -10,6 +10,7 @@ import {
calcRiderMetrics, groupRowsByRider,
} from '@/lib/profitability';
import { currency, km as formatKm, number } from '@/lib/doormileFormat';
import ControlXDatePicker from '@/components/doormile/ControlXDatePicker';
/**
* Profitability — what a day of deliveries actually earned, per rider.
@@ -163,16 +164,10 @@ export default function Profitability({ embedded = false, date: controlledDate,
{!embedded && (
<PageHeader
actions={
<Input
type="date"
<ControlXDatePicker
value={date}
// Profitability reports on a day that has happened. Without a
// ceiling the picker happily offers next year, which returns an
// empty report that reads as "no profit" rather than "no data".
max={dayjs().format('YYYY-MM-DD')}
onChange={(e) => e.target.value && setDate(e.target.value)}
aria-label="Report date"
className="w-44"
onChange={setDate}
maxDate={dayjs().format('YYYY-MM-DD')}
/>
}
/>

View File

@@ -5,12 +5,12 @@ import {
Alert, Button, DataTable, Drawer, EmptyState, Grid, IconButton, KpiCard, PageHeader, Pagination, SearchInput, Stack,
StatusBadge, Surface, Tabs,
} from '@/components/ds';
import { DateRangeFields } from '@/components/ds/DateRangeFields';
import { useAuth } from '@/lib/AuthContext';
import { useReturns, useReturnsSummary } from '@/lib/doormileHooks';
import { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
import ControlXDatePicker from '@/components/doormile/ControlXDatePicker';
/**
* Returns — parcels going back to the sender (RTO) and those already back.
@@ -244,12 +244,13 @@ export default function Returns() {
}}
/>
<div className="flex flex-wrap items-center gap-2">
<DateRangeFields
<ControlXDatePicker
value={range}
onChange={(next) => {
setRange(next);
setPage(1);
}}
maxDate={dayjs().format('YYYY-MM-DD')}
/>
<SearchInput value={search} onChange={setSearch} placeholder="Tracking no, client, reason, miler" className="w-full sm:w-64" />
</div>

View File

@@ -8,6 +8,7 @@ import { Input } from '@/components/ds';
import Customers from '@/pages/doormile/customers/Customers';
import Profitability from '@/pages/doormile/reports/Profitability';
import AgentStudio from './agentStudio/AgentStudio';
import ControlXDatePicker from '@/components/doormile/ControlXDatePicker';
import { useAuth } from '@/lib/AuthContext';
import { canOnboardClients } from '@/lib/clientOnboarding';
@@ -110,17 +111,10 @@ export default function Settings() {
{/* Tab Context Action aligned directly on the right */}
<div className="flex items-center gap-2">
{activeTab === 'profitability' && (
<Input
type="date"
<ControlXDatePicker
value={reportDate}
// Profitability reports on a day that has happened. Profitability.jsx
// has its own bounded input for the standalone page; this is the
// embedded copy, and it was missed — a future date here returns an
// empty report that reads as "no profit" rather than "no data".
max={dayjs().format('YYYY-MM-DD')}
onChange={(e) => 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')}
/>
)}
</div>

View File

@@ -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) => <span data-testid={`icon-${String(prop)}`} {...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);
});
});