updates on the datepciker and the reverse logistics
This commit is contained in:
@@ -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)' },
|
||||
];
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
424
src/components/doormile/ControlXDatePicker.css
Normal file
424
src/components/doormile/ControlXDatePicker.css
Normal 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;
|
||||
}
|
||||
436
src/components/doormile/ControlXDatePicker.jsx
Normal file
436
src/components/doormile/ControlXDatePicker.jsx
Normal 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']),
|
||||
};
|
||||
@@ -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 !== '';
|
||||
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;
|
||||
@@ -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 }) {
|
||||
<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'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
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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')}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
150
tests/lib/reverseLogistics7Days.test.js
Normal file
150
tests/lib/reverseLogistics7Days.test.js
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user