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: 'customer_unavailable', label: 'Customer unavailable' },
|
||||||
{ value: 'attempts_exhausted', label: 'Delivery attempts exhausted' },
|
{ value: 'attempts_exhausted', label: 'Delivery attempts exhausted' },
|
||||||
{ value: 'damaged', label: 'Damaged in transit' },
|
{ 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)' },
|
{ value: 'other', label: 'Other (describe)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1073,11 +1073,21 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
|||||||
deliveryamount: charge,
|
deliveryamount: charge,
|
||||||
Quantity: b.parcels?.length || 0,
|
Quantity: b.parcels?.length || 0,
|
||||||
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,
|
collectionamt: undefined,
|
||||||
notes: b.notes || '',
|
notes: b.notes || '',
|
||||||
deliverytype: customer ? 'B' : 'C',
|
deliverytype: customer ? 'B' : 'C',
|
||||||
orderdate: b.createdat || b.orderdate || b.updatedat,
|
orderdate: b.createdat || b.orderdate || b.updatedat,
|
||||||
deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || 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,
|
reachedat: reached,
|
||||||
assigntime: b.updatedat || b.createdat,
|
assigntime: b.updatedat || b.createdat,
|
||||||
// The consignment's status WINS when there is one (e.g. Collected_By_Miler -> Picked,
|
// 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 React, { useEffect, useState } from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
import { PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
|
import { PackageCheck, RotateCcw, Undo2 } from 'lucide-react';
|
||||||
import { Alert, Button, Field, Modal, Textarea } from '@/components/ds';
|
import { Alert, Button, Field, Modal, Textarea } from '@/components/ds';
|
||||||
import { inputVariants } from '@/components/ui/input';
|
import { inputVariants } from '@/components/ui/input';
|
||||||
import { RTO_REASONS } from '@/api/doormile/endpoints';
|
import { RTO_REASONS } from '@/api/doormile/endpoints';
|
||||||
import { useCancelRto, useCompleteRto, useInitiateRto } from '@/lib/doormileHooks';
|
import { useCancelRto, useCompleteRto, useInitiateRto } from '@/lib/doormileHooks';
|
||||||
|
import { isPerishableCategory } from '@/lib/clientOnboarding';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reverse logistics (RTO) dialogs, shared by Deliveries, Returns and
|
* Reverse logistics (RTO) dialogs, shared by Deliveries, Returns and
|
||||||
* Exceptions. Plan: docs/reverse-logistics-plan.md.
|
* 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
|
* A return can start:
|
||||||
* backend refuses anything else (delivered, cancelled, already returning) and
|
* 1. While the parcel is in transit or at a base (in-transit RTO).
|
||||||
* its message is shown in the dialog. Every action needs the CONSIGNMENT id —
|
* 2. Within a 7-day post-delivery window (Amazon-style customer return).
|
||||||
* a booking that has not been picked up has no parcel to return yet.
|
* 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'];
|
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) {
|
export function rtoActionsFor(row) {
|
||||||
const status = String(row?.orderstatus || '').toLowerCase();
|
const status = String(row?.orderstatus || '').toLowerCase();
|
||||||
const hasParcel = row?.consignmentid != null && row?.consignmentid !== '';
|
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 {
|
return {
|
||||||
canStart: hasParcel && RTO_STARTABLE.includes(status),
|
canStart: isEligible,
|
||||||
canResolve: hasParcel && status === 'rto',
|
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;
|
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) =>
|
const subtitleFor = (row) =>
|
||||||
row ? row.label || `Order ${row.orderid ?? row.trackingno ?? row.consignmentid}` : undefined;
|
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 }) {
|
export function StartRtoModal({ row, onClose }) {
|
||||||
const start = useInitiateRto();
|
const start = useInitiateRto();
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [note, setNote] = useState('');
|
const [note, setNote] = useState('');
|
||||||
const [error, setError] = 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).
|
// `row.defaultReason` pre-selects a reason (e.g. from an exception's type).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setReason(row?.defaultReason || '');
|
setReason(row?.defaultReason || '');
|
||||||
@@ -70,17 +131,17 @@ export function StartRtoModal({ row, onClose }) {
|
|||||||
<Modal
|
<Modal
|
||||||
open={Boolean(row)}
|
open={Boolean(row)}
|
||||||
onOpenChange={(open) => !open && onClose()}
|
onOpenChange={(open) => !open && onClose()}
|
||||||
title="Return to sender"
|
title={isPostDelivery ? "Customer return" : "Return to sender"}
|
||||||
description={subtitleFor(row)}
|
description={subtitleFor(row)}
|
||||||
icon={Undo2}
|
icon={Undo2}
|
||||||
busy={start.isPending}
|
busy={start.isPending}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
<Button variant="ghost" onClick={onClose} disabled={start.isPending}>
|
<Button variant="ghost" onClick={onClose} disabled={start.isPending}>
|
||||||
Keep delivering
|
{isPostDelivery ? "Keep order" : "Keep delivering"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={submit} loading={start.isPending} disabled={!canSubmit}>
|
<Button variant="destructive" onClick={submit} loading={start.isPending} disabled={!canSubmit}>
|
||||||
Start return
|
{isPostDelivery ? "Start customer return" : "Start return"}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
@@ -91,7 +152,9 @@ export function StartRtoModal({ row, onClose }) {
|
|||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
<p className="mb-3 text-body-sm text-ink-2">
|
<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>
|
</p>
|
||||||
<Field label="Reason" required>
|
<Field label="Reason" required>
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -45,21 +45,49 @@ export function generateClientPassword(length = 14) {
|
|||||||
*/
|
*/
|
||||||
export const DELIVERY_CATEGORIES = [
|
export const DELIVERY_CATEGORIES = [
|
||||||
'General', 'Documents', 'Electronics', 'Clothing',
|
'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.
|
* Non-returnable categories (Food, Meat, Fresh Produce).
|
||||||
*
|
* Food and fresh items that come back produce waste and cannot be restocked.
|
||||||
* Food is the exception: a meal that comes back is waste, not inventory.
|
* Durable products like Steel, Electronics, Clothing, etc. have standard return paths.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
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) {
|
export function validateOnboarding(form) {
|
||||||
const errors = {};
|
const errors = {};
|
||||||
|
|||||||
@@ -31,4 +31,4 @@ export function cn(...inputs) {
|
|||||||
return twMerge(clsx(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 ? (
|
{!isClient && rtoActionsFor(row).canStart ? (
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Return to sender"
|
label={row.orderstatus === 'delivered' ? `Customer return (${rtoActionsFor(row).daysRemaining}d left)` : "Return to sender"}
|
||||||
icon={Undo2}
|
icon={Undo2}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
calcRiderMetrics, groupRowsByRider,
|
calcRiderMetrics, groupRowsByRider,
|
||||||
} from '@/lib/profitability';
|
} from '@/lib/profitability';
|
||||||
import { currency, km as formatKm, number } from '@/lib/doormileFormat';
|
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.
|
* Profitability — what a day of deliveries actually earned, per rider.
|
||||||
@@ -163,16 +164,10 @@ export default function Profitability({ embedded = false, date: controlledDate,
|
|||||||
{!embedded && (
|
{!embedded && (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
actions={
|
actions={
|
||||||
<Input
|
<ControlXDatePicker
|
||||||
type="date"
|
|
||||||
value={date}
|
value={date}
|
||||||
// Profitability reports on a day that has happened. Without a
|
onChange={setDate}
|
||||||
// ceiling the picker happily offers next year, which returns an
|
maxDate={dayjs().format('YYYY-MM-DD')}
|
||||||
// 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"
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import {
|
|||||||
Alert, Button, DataTable, Drawer, EmptyState, Grid, IconButton, KpiCard, PageHeader, Pagination, SearchInput, Stack,
|
Alert, Button, DataTable, Drawer, EmptyState, Grid, IconButton, KpiCard, PageHeader, Pagination, SearchInput, Stack,
|
||||||
StatusBadge, Surface, Tabs,
|
StatusBadge, Surface, Tabs,
|
||||||
} from '@/components/ds';
|
} from '@/components/ds';
|
||||||
import { DateRangeFields } from '@/components/ds/DateRangeFields';
|
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { useReturns, useReturnsSummary } from '@/lib/doormileHooks';
|
import { useReturns, useReturnsSummary } from '@/lib/doormileHooks';
|
||||||
import { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
import { exportRows, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||||
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
|
import { ResolveRtoModal } from '@/components/doormile/RtoDialogs';
|
||||||
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
|
import ConsignmentTimeline from '@/components/doormile/ConsignmentTimeline';
|
||||||
|
import ControlXDatePicker from '@/components/doormile/ControlXDatePicker';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns — parcels going back to the sender (RTO) and those already back.
|
* 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">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<DateRangeFields
|
<ControlXDatePicker
|
||||||
value={range}
|
value={range}
|
||||||
onChange={(next) => {
|
onChange={(next) => {
|
||||||
setRange(next);
|
setRange(next);
|
||||||
setPage(1);
|
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" />
|
<SearchInput value={search} onChange={setSearch} placeholder="Tracking no, client, reason, miler" className="w-full sm:w-64" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Input } from '@/components/ds';
|
|||||||
import Customers from '@/pages/doormile/customers/Customers';
|
import Customers from '@/pages/doormile/customers/Customers';
|
||||||
import Profitability from '@/pages/doormile/reports/Profitability';
|
import Profitability from '@/pages/doormile/reports/Profitability';
|
||||||
import AgentStudio from './agentStudio/AgentStudio';
|
import AgentStudio from './agentStudio/AgentStudio';
|
||||||
|
import ControlXDatePicker from '@/components/doormile/ControlXDatePicker';
|
||||||
import { useAuth } from '@/lib/AuthContext';
|
import { useAuth } from '@/lib/AuthContext';
|
||||||
import { canOnboardClients } from '@/lib/clientOnboarding';
|
import { canOnboardClients } from '@/lib/clientOnboarding';
|
||||||
|
|
||||||
@@ -110,17 +111,10 @@ export default function Settings() {
|
|||||||
{/* Tab Context Action aligned directly on the right */}
|
{/* Tab Context Action aligned directly on the right */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{activeTab === 'profitability' && (
|
{activeTab === 'profitability' && (
|
||||||
<Input
|
<ControlXDatePicker
|
||||||
type="date"
|
|
||||||
value={reportDate}
|
value={reportDate}
|
||||||
// Profitability reports on a day that has happened. Profitability.jsx
|
onChange={setReportDate}
|
||||||
// has its own bounded input for the standalone page; this is the
|
maxDate={dayjs().format('YYYY-MM-DD')}
|
||||||
// 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"
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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