diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index d294301..95ee19f 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -930,6 +930,11 @@ export const RTO_REASONS = [ { value: 'customer_unavailable', label: 'Customer unavailable' }, { value: 'attempts_exhausted', label: 'Delivery attempts exhausted' }, { value: 'damaged', label: 'Damaged in transit' }, + { value: 'defective', label: 'Defective or damaged item' }, + { value: 'wrong_item', label: 'Wrong item delivered' }, + { value: 'quality_issue', label: 'Quality not as expected' }, + { value: 'size_mismatch', label: 'Size or fit mismatch' }, + { value: 'not_needed', label: 'No longer needed' }, { value: 'other', label: 'Other (describe)' }, ]; diff --git a/src/api/doormile/queries.js b/src/api/doormile/queries.js index 071fcff..f04e064 100644 --- a/src/api/doormile/queries.js +++ b/src/api/doormile/queries.js @@ -1073,11 +1073,21 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { deliveryamount: charge, Quantity: b.parcels?.length || 0, quantity: b.parcels?.length || 0, + deliverycategory: tenant?.deliverycategory || b.deliverycategory || '', + reverselogisticsenabled: tenant?.reverselogisticsenabled, + itemcategory: b.parcels?.[0]?.itemcategory || b.itemcategory || '', + parcels: b.parcels || [], collectionamt: undefined, notes: b.notes || '', deliverytype: customer ? 'B' : 'C', orderdate: b.createdat || b.orderdate || b.updatedat, deliverydate: b.serviceoptions?.[0]?.estimateddeliveryat || b.updatedat, + deliveredat: + consignment?.deliveredat || + b.deliveredat || + b.destinations?.find((d) => d.deliveredat)?.deliveredat || + (effectiveDeliveryStatus === 'delivered' ? (consignment?.updatedat || b.updatedat) : undefined), + updatedat: consignment?.updatedat || b.updatedat, reachedat: reached, assigntime: b.updatedat || b.createdat, // The consignment's status WINS when there is one (e.g. Collected_By_Miler -> Picked, diff --git a/src/components/doormile/ControlXDatePicker.css b/src/components/doormile/ControlXDatePicker.css new file mode 100644 index 0000000..a359c90 --- /dev/null +++ b/src/components/doormile/ControlXDatePicker.css @@ -0,0 +1,424 @@ +.controlx-datepicker { + position: relative; + display: inline-flex; + vertical-align: middle; +} + +.controlx-datepicker .date-chip { + position: relative; + display: inline-flex; + align-items: center; + gap: 0; + height: 34px; + max-height: 34px; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.12); + border-radius: 8px; + box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04); + transition: border-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; + flex-shrink: 0; + box-sizing: border-box; +} + +.controlx-datepicker .date-chip.is-open { + border-color: #000000; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.15), 0 8px 20px rgba(0, 0, 0, 0.14); +} + +.controlx-datepicker .date-chip:hover { + border-color: rgba(0, 0, 0, 0.35); + box-shadow: 0 2px 5px rgba(15, 23, 42, 0.06); +} + +.controlx-datepicker .date-chip:focus-within { + border-color: #000000; + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.15); +} + +.controlx-datepicker .date-chip-nav { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 100%; + box-sizing: border-box; + border: 0; + background: rgba(248, 250, 252, 0.7); + color: #64748b; + font-size: 18px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.controlx-datepicker .date-chip-nav:first-child { + border-right: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 7px 0 0 7px; +} + +.controlx-datepicker .date-chip-nav:last-child { + border-left: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 0 7px 7px 0; +} + +.controlx-datepicker .date-chip-nav:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.08); + color: #262626; +} + +.controlx-datepicker .date-chip-nav:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.controlx-datepicker .date-chip-nav:focus-visible { + outline: 2px solid rgba(0, 0, 0, 0.5); + outline-offset: -2px; +} + +.controlx-datepicker .date-chip-main { + appearance: none; + position: relative; + display: inline-flex; + align-items: center; + gap: 7px; + height: 100%; + padding: 0 10px; + cursor: pointer; + min-width: 0; + background: transparent; + border: 0; + color: inherit; + font: inherit; + text-align: left; + box-sizing: border-box; +} + +.controlx-datepicker .date-chip-main:focus { + outline: none; +} + +.controlx-datepicker .date-chip-main:hover { + background: rgba(0, 0, 0, 0.03); +} + +.controlx-datepicker .date-chip-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + border-radius: 4px; + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); + color: #334155; + font-size: 12px; + flex-shrink: 0; +} + +.controlx-datepicker .date-chip-text { + display: flex; + flex-direction: row; + align-items: center; + line-height: 1; + gap: 6px; + min-width: 0; +} + +.controlx-datepicker .date-chip-today-pill { + display: inline-flex; + align-items: center; + padding: 1px 5px; + border-radius: 999px; + background: #0f172a; + color: #ffffff; + font-size: 8.5px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.controlx-datepicker .date-chip-value { + font-size: 12px; + font-weight: 600; + color: #0f172a; + letter-spacing: 0.01em; + white-space: nowrap; +} + +.controlx-datepicker .date-chip-chevron { + display: inline-flex; + align-items: center; + justify-content: center; + color: #94a3b8; + font-size: 16px; + margin-left: 2px; + transition: color 0.18s ease, transform 0.18s ease; +} + +.controlx-datepicker .date-chip-main:hover .date-chip-chevron { + color: #262626; +} + +.controlx-datepicker .date-chip-chevron.is-open { + color: #262626; + transform: rotate(180deg); +} + +/* ── Calendar Popover ─────────────────────────────────────────── */ +.controlx-datepicker .date-cal-popover { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 1000; + width: 320px; + padding: 14px 14px 10px; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 8px; + box-shadow: 0 18px 44px rgba(15, 23, 42, 0.18), + 0 4px 12px rgba(15, 23, 42, 0.06); + animation: controlx-date-cal-in 0.18s cubic-bezier(0.4, 0, 0.2, 1); + box-sizing: border-box; +} + +.controlx-datepicker .date-cal-popover.align-left { + right: auto; + left: 0; +} + +.controlx-datepicker .date-cal-popover::before, +.controlx-datepicker .date-cal-popover::after { + content: ''; + position: absolute; + bottom: 100%; + right: 28px; + width: 0; + height: 0; + border-left: 8px solid transparent; + border-right: 8px solid transparent; +} + +.controlx-datepicker .date-cal-popover.align-left::before, +.controlx-datepicker .date-cal-popover.align-left::after { + right: auto; + left: 28px; +} + +.controlx-datepicker .date-cal-popover::before { + border-bottom: 8px solid rgba(15, 23, 42, 0.08); +} + +.controlx-datepicker .date-cal-popover::after { + border-bottom: 8px solid #ffffff; + margin-bottom: -1px; +} + +@keyframes controlx-date-cal-in { + from { + opacity: 0; + transform: translateY(-6px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.controlx-datepicker .date-cal-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 10px; +} + +.controlx-datepicker .date-cal-title { + flex: 1; + text-align: center; + font-size: 14px; + font-weight: 800; + color: #0f172a; + letter-spacing: 0.01em; +} + +.controlx-datepicker .date-cal-nav { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + border: 0; + border-radius: 6px; + background: rgba(248, 250, 252, 0.9); + color: #475569; + font-size: 18px; + cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; +} + +.controlx-datepicker .date-cal-nav:hover:not(:disabled) { + background: rgba(0, 0, 0, 0.1); + color: #262626; +} + +.controlx-datepicker .date-cal-nav:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.controlx-datepicker .date-cal-hint { + font-size: 11px; + font-weight: 600; + color: #2563eb; + text-align: center; + margin-bottom: 6px; +} + +.controlx-datepicker .date-cal-weekdays, +.controlx-datepicker .date-cal-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.controlx-datepicker .date-cal-weekdays { + margin-bottom: 4px; +} + +.controlx-datepicker .date-cal-weekday { + text-align: center; + font-size: 10px; + font-weight: 800; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.06em; + padding: 4px 0; +} + +.controlx-datepicker .date-cal-day { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + height: 36px; + border: 0; + border-radius: 6px; + background: transparent; + color: #0f172a; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease, box-shadow 0.18s ease, transform 0.12s ease; +} + +.controlx-datepicker .date-cal-day:hover:not(:disabled):not(.is-selected):not(.is-range-start):not(.is-range-end) { + background: rgba(0, 0, 0, 0.08); + color: #262626; +} + +.controlx-datepicker .date-cal-day.is-other-month { + color: #cbd5e1; + font-weight: 500; +} + +.controlx-datepicker .date-cal-day.is-today { + box-shadow: inset 0 0 0 1.5px rgba(0, 0, 0, 0.55); + color: #262626; + font-weight: 800; +} + +/* Single date selected */ +.controlx-datepicker .date-cal-day.is-selected { + background: linear-gradient(135deg, #000000, #3A3A3A); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); + font-weight: 800; +} + +.controlx-datepicker .date-cal-day.is-selected.is-today { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35); +} + +.controlx-datepicker .date-cal-day.is-selected:hover { + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45); +} + +/* Range selection styles */ +.controlx-datepicker .date-cal-day.is-in-range { + background: rgba(15, 23, 42, 0.07); + color: #0f172a; + border-radius: 0; +} + +.controlx-datepicker .date-cal-day.is-range-start { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + background: linear-gradient(135deg, #000000, #3A3A3A); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + font-weight: 800; +} + +.controlx-datepicker .date-cal-day.is-range-end { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + background: linear-gradient(135deg, #000000, #3A3A3A); + color: #ffffff; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25); + font-weight: 800; +} + +.controlx-datepicker .date-cal-day.is-range-start.is-range-end { + border-radius: 6px; +} + +.controlx-datepicker .date-cal-day.is-disabled { + color: #e2e8f0; + cursor: not-allowed; + pointer-events: none; +} + +.controlx-datepicker .date-cal-day:focus-visible { + outline: 2px solid rgba(0, 0, 0, 0.5); + outline-offset: 1px; +} + +.controlx-datepicker .date-cal-presets { + display: flex; + flex-wrap: wrap; + gap: 5px; + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid rgba(15, 23, 42, 0.06); +} + +.controlx-datepicker .date-cal-preset { + appearance: none; + flex: 1 1 calc(33.333% - 4px); + padding: 6px 6px; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 6px; + background: #ffffff; + color: #475569; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.01em; + text-align: center; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.12s ease; +} + +.controlx-datepicker .date-cal-preset:hover { + background: rgba(0, 0, 0, 0.08); + border-color: rgba(0, 0, 0, 0.35); + color: #262626; + transform: translateY(-1px); +} + +.controlx-datepicker .date-cal-preset:focus-visible { + outline: 2px solid rgba(0, 0, 0, 0.5); + outline-offset: 1px; +} diff --git a/src/components/doormile/ControlXDatePicker.jsx b/src/components/doormile/ControlXDatePicker.jsx new file mode 100644 index 0000000..e5d090d --- /dev/null +++ b/src/components/doormile/ControlXDatePicker.jsx @@ -0,0 +1,436 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import PropTypes from 'prop-types'; +import dayjs from 'dayjs'; +import { MdCalendarToday, MdChevronLeft, MdChevronRight, MdExpandMore } from 'react-icons/md'; +import './ControlXDatePicker.css'; + +/** + * ControlXDatePicker — The signature Control-X (Dispatch) date selector. + * + * Supports: + * - Single date mode: value = 'YYYY-MM-DD' + * - Range mode: value = { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' } + * - 1-click scrubbing (< and >) + * - Sleek trigger chip with calendar badge, "Today" pill, and formatted date(s) + * - Month navigation with future dates disabled + * - Range drag / preview highlighting + * - Quick presets (Today, Yesterday, Last 7d, Last 30d, This month) + */ +export default function ControlXDatePicker({ + value, + onChange, + range: forceRange, + maxDate, + className = '', + align = 'right', +}) { + const [open, setOpen] = useState(false); + const [pendingFrom, setPendingFrom] = useState(null); + const [hoverDate, setHoverDate] = useState(null); + const containerRef = useRef(null); + + const today = useMemo(() => dayjs().startOf('day'), []); + const todayStr = useMemo(() => today.format('YYYY-MM-DD'), [today]); + const yesterdayStr = useMemo(() => today.subtract(1, 'day').format('YYYY-MM-DD'), [today]); + + const ceilingDate = useMemo(() => { + if (maxDate && dayjs(maxDate).isValid()) { + return dayjs(maxDate).startOf('day'); + } + return today; + }, [maxDate, today]); + + // Determine whether this picker is in range mode + const isRange = useMemo(() => { + if (typeof forceRange === 'boolean') return forceRange; + return typeof value === 'object' && value !== null; + }, [forceRange, value]); + + // Normalize parsed values + const parsedRange = useMemo(() => { + if (isRange) { + const from = value?.from || todayStr; + const to = value?.to || todayStr; + return { from, to }; + } + const single = typeof value === 'string' && value ? value : todayStr; + return { from: single, to: single }; + }, [isRange, value, todayStr]); + + const isToday = useMemo(() => { + if (isRange) { + return parsedRange.from === todayStr && parsedRange.to === todayStr; + } + return parsedRange.from === todayStr; + }, [isRange, parsedRange, todayStr]); + + const [calViewMonth, setCalViewMonth] = useState(() => { + const initial = dayjs(parsedRange.to || parsedRange.from); + return initial.isValid() ? initial.startOf('month') : today.startOf('month'); + }); + + // Close calendar popover on click outside or Escape + useEffect(() => { + if (!open) return undefined; + const handleClickOutside = (e) => { + if (!containerRef.current) return; + if (!containerRef.current.contains(e.target)) { + setOpen(false); + setPendingFrom(null); + setHoverDate(null); + } + }; + const handleKey = (e) => { + if (e.key === 'Escape') { + setOpen(false); + setPendingFrom(null); + setHoverDate(null); + } + }; + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKey); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKey); + }; + }, [open]); + + // Sync visible month when popover opens + useEffect(() => { + if (open) { + const ref = dayjs(parsedRange.to || parsedRange.from); + setCalViewMonth(ref.isValid() ? ref.startOf('month') : today.startOf('month')); + setPendingFrom(null); + setHoverDate(null); + } + }, [open, parsedRange.from, parsedRange.to]); + + const commitSingle = (nextDayjs) => { + if (!nextDayjs || !nextDayjs.isValid()) return; + if (nextDayjs.isAfter(ceilingDate, 'day')) return; + const str = nextDayjs.format('YYYY-MM-DD'); + if (onChange) onChange(str); + setOpen(false); + }; + + const commitRange = (fromStr, toStr) => { + const [start, end] = fromStr <= toStr ? [fromStr, toStr] : [toStr, fromStr]; + if (onChange) onChange({ from: start, to: end }); + setPendingFrom(null); + setHoverDate(null); + setOpen(false); + }; + + // Day scrubbing (< and >) + const goPrev = (e) => { + e.stopPropagation(); + if (isRange) { + const startD = dayjs(parsedRange.from); + const endD = dayjs(parsedRange.to); + const span = Math.max(1, endD.diff(startD, 'day') + 1); + const newFrom = startD.subtract(span, 'day').format('YYYY-MM-DD'); + const newTo = endD.subtract(span, 'day').format('YYYY-MM-DD'); + if (onChange) onChange({ from: newFrom, to: newTo }); + } else { + const base = dayjs(parsedRange.from); + commitSingle(base.subtract(1, 'day')); + } + }; + + const goNext = (e) => { + e.stopPropagation(); + if (isRange) { + const startD = dayjs(parsedRange.from); + const endD = dayjs(parsedRange.to); + const span = Math.max(1, endD.diff(startD, 'day') + 1); + let newEnd = endD.add(span, 'day'); + let newStart = startD.add(span, 'day'); + if (newEnd.isAfter(ceilingDate, 'day')) { + newEnd = ceilingDate; + newStart = ceilingDate.subtract(span - 1, 'day'); + } + if (onChange) onChange({ from: newStart.format('YYYY-MM-DD'), to: newEnd.format('YYYY-MM-DD') }); + } else { + const base = dayjs(parsedRange.from); + if (base.isSame(ceilingDate, 'day') || base.isAfter(ceilingDate, 'day')) return; + commitSingle(base.add(1, 'day')); + } + }; + + const canGoNext = useMemo(() => { + if (isRange) { + const endD = dayjs(parsedRange.to); + return endD.isBefore(ceilingDate, 'day'); + } + const base = dayjs(parsedRange.from); + return base.isBefore(ceilingDate, 'day'); + }, [isRange, parsedRange, ceilingDate]); + + // Month navigation + const prevMonth = () => setCalViewMonth((m) => m.subtract(1, 'month')); + const nextMonth = () => { + const candidate = calViewMonth.add(1, 'month'); + if (candidate.startOf('month').isAfter(ceilingDate, 'month')) return; + setCalViewMonth(candidate); + }; + const canGoNextMonth = !calViewMonth.add(1, 'month').startOf('month').isAfter(ceilingDate, 'month'); + + // Build fixed 6x7 grid (42 cells) + const monthStart = calViewMonth.startOf('month'); + const gridStart = monthStart.subtract(monthStart.day(), 'day'); + const cells = useMemo( + () => Array.from({ length: 42 }, (_, i) => gridStart.add(i, 'day')), + [gridStart] + ); + + const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + + // Current active range (preview while pending, or committed) + const activeRange = useMemo(() => { + if (!isRange) return null; + if (pendingFrom) { + const end = hoverDate || pendingFrom; + return pendingFrom <= end ? { from: pendingFrom, to: end } : { from: end, to: pendingFrom }; + } + return { from: parsedRange.from, to: parsedRange.to }; + }, [isRange, pendingFrom, hoverDate, parsedRange]); + + const handleDayClick = (dayStr) => { + if (isRange) { + if (!pendingFrom) { + setPendingFrom(dayStr); + setHoverDate(dayStr); + } else { + commitRange(pendingFrom, dayStr); + } + } else { + commitSingle(dayjs(dayStr)); + } + }; + + // Label formatting on trigger chip + const triggerLabel = useMemo(() => { + if (isRange) { + const f = dayjs(parsedRange.from); + const t = dayjs(parsedRange.to); + if (!f.isValid() || !t.isValid()) return '—'; + if (parsedRange.from === parsedRange.to) { + return f.format('ddd, MMM D, YYYY'); + } + return `${f.format('MMM D')} → ${t.format('MMM D, YYYY')}`; + } + const d = dayjs(parsedRange.from); + return d.isValid() ? d.format('ddd, MMM D, YYYY') : '—'; + }, [isRange, parsedRange]); + + return ( +
- 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."}