updates on the minor changes like zone updates and datepicker fix
This commit is contained in:
@@ -24,8 +24,27 @@ export function DatePicker({
|
||||
placeholder = 'Select date',
|
||||
disabled = false,
|
||||
align = 'end',
|
||||
// Opt-in, so pages that pick ONE day (Orders, Deliveries) are unchanged.
|
||||
// rangeSelect: the first calendar click sets the start and keeps the
|
||||
// calendar open; the second sets the end and commits { from, to }. Clicking
|
||||
// the end before the start is fine — the two are put in order.
|
||||
rangeSelect = false,
|
||||
// maxDate ('YYYY-MM-DD'): days after it are greyed out and can't be picked.
|
||||
maxDate,
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
// rangeSelect only: the start day picked by the first click, and the day
|
||||
// under the pointer, so the span between them previews before the 2nd click.
|
||||
const [pendingFrom, setPendingFrom] = useState(null);
|
||||
const [hoverDate, setHoverDate] = useState(null);
|
||||
|
||||
const handleOpenChange = (next) => {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setPendingFrom(null);
|
||||
setHoverDate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const today = useMemo(() => dayjs().startOf('day'), []);
|
||||
const todayStr = useMemo(() => today.format('YYYY-MM-DD'), [today]);
|
||||
@@ -110,6 +129,28 @@ export function DatePicker({
|
||||
if (onChange) onChange(nextVal);
|
||||
} finally {
|
||||
setOpen(false);
|
||||
setPendingFrom(null);
|
||||
setHoverDate(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isAfterMax = (dateStr) => Boolean(maxDate) && dateStr > maxDate;
|
||||
|
||||
const handleDayClick = (dateStr) => {
|
||||
if (isAfterMax(dateStr)) return;
|
||||
if (rangeSelect) {
|
||||
if (!pendingFrom) {
|
||||
setPendingFrom(dateStr);
|
||||
return;
|
||||
}
|
||||
const [from, to] = pendingFrom <= dateStr ? [pendingFrom, dateStr] : [dateStr, pendingFrom];
|
||||
commit({ from, to });
|
||||
return;
|
||||
}
|
||||
if (typeof value === 'object' && !parsed.isSingle && value.to) {
|
||||
commit({ from: dateStr, to: dateStr });
|
||||
} else {
|
||||
commit(dateStr);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,9 +188,18 @@ export function DatePicker({
|
||||
|
||||
const WEEKDAYS = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
|
||||
// While a range is half-picked, highlight the start → hovered day instead of
|
||||
// the committed value, so the operator sees what the second click will pick.
|
||||
const pendingRange = useMemo(() => {
|
||||
if (!pendingFrom) return null;
|
||||
const end = hoverDate && !isAfterMax(hoverDate) ? hoverDate : pendingFrom;
|
||||
return pendingFrom <= end ? { from: pendingFrom, to: end } : { from: end, to: pendingFrom };
|
||||
}, [pendingFrom, hoverDate, maxDate]);
|
||||
|
||||
const isSelected = (day) => {
|
||||
if (parsed.isAll) return false;
|
||||
const s = day.format('YYYY-MM-DD');
|
||||
if (pendingRange) return s >= pendingRange.from && s <= pendingRange.to;
|
||||
if (parsed.isAll) return false;
|
||||
if (parsed.from && parsed.to) {
|
||||
return s >= parsed.from && s <= parsed.to;
|
||||
}
|
||||
@@ -157,13 +207,14 @@ export function DatePicker({
|
||||
};
|
||||
|
||||
const isRangeEndpoint = (day) => {
|
||||
if (parsed.isAll) return false;
|
||||
const s = day.format('YYYY-MM-DD');
|
||||
if (pendingRange) return s === pendingRange.from || s === pendingRange.to;
|
||||
if (parsed.isAll) return false;
|
||||
return s === parsed.from || s === parsed.to;
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={handleOpenChange}>
|
||||
<div className={cn('inline-flex items-center rounded-lg border border-border bg-card shadow-2xs transition-colors', className)}>
|
||||
{showSteppers && (
|
||||
<button
|
||||
@@ -255,20 +306,18 @@ export function DatePicker({
|
||||
const selected = isSelected(d);
|
||||
const isTodayCell = dateStr === todayStr;
|
||||
const endpoint = isRangeEndpoint(d);
|
||||
const blocked = isAfterMax(dateStr);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dateStr}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (typeof value === 'object' && !parsed.isSingle && value.to) {
|
||||
commit({ from: dateStr, to: dateStr });
|
||||
} else {
|
||||
commit(dateStr);
|
||||
}
|
||||
}}
|
||||
disabled={blocked}
|
||||
onClick={() => handleDayClick(dateStr)}
|
||||
onMouseEnter={rangeSelect && pendingFrom ? () => setHoverDate(dateStr) : undefined}
|
||||
className={cn(
|
||||
'flex h-7 w-full items-center justify-center rounded-md text-xs font-medium transition-colors relative',
|
||||
blocked && 'cursor-not-allowed opacity-30 hover:bg-transparent',
|
||||
!inMonth && 'text-ink-4/40 hover:text-ink-2',
|
||||
inMonth && !selected && 'text-ink-1 hover:bg-muted',
|
||||
selected && !endpoint && 'bg-primary/15 text-primary rounded-none',
|
||||
@@ -285,6 +334,14 @@ export function DatePicker({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{rangeSelect && (
|
||||
<p className="pt-2 text-center text-[11px] text-ink-3" aria-live="polite">
|
||||
{pendingFrom
|
||||
? `From ${dayjs(pendingFrom).format('DD MMM')} — now pick the end date`
|
||||
: 'Pick a start date, then an end date'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Quick Presets */}
|
||||
<div className="mt-3 pt-2.5 border-t border-border/60 flex flex-wrap gap-1">
|
||||
<button
|
||||
@@ -372,6 +429,8 @@ DatePicker.propTypes = {
|
||||
placeholder: PropTypes.string,
|
||||
disabled: PropTypes.bool,
|
||||
align: PropTypes.oneOf(['start', 'center', 'end']),
|
||||
rangeSelect: PropTypes.bool,
|
||||
maxDate: PropTypes.string,
|
||||
};
|
||||
|
||||
export default DatePicker;
|
||||
|
||||
@@ -124,7 +124,16 @@ export function ZoneProvider({ children }) {
|
||||
if (isTenantUser) {
|
||||
return true;
|
||||
}
|
||||
if (item.applocationid != null && (String(item.applocationid) === targetHubId || String(item.applocationid) === String(currentHub.applocationid))) {
|
||||
// A rider's applocationid is a CITY id; the selected zone is a HUB. Only
|
||||
// compare city with city. This used to also test applocationid against
|
||||
// the hub id — two unrelated number spaces — so with "Coimbatore
|
||||
// Neptune Hub" (hubid 2) selected, every Hyderabad rider (city 2)
|
||||
// matched and the miler lists showed riders from another city.
|
||||
if (
|
||||
item.applocationid != null &&
|
||||
currentHub.applocationid != null &&
|
||||
String(item.applocationid) === String(currentHub.applocationid)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (item.hubid != null && String(item.hubid) === targetHubId) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
/**
|
||||
* The DatePicker's value, as a `{ from, to }` range.
|
||||
*
|
||||
@@ -49,3 +51,23 @@ export const filterByCreatedAt = (rows, value, parse) => {
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* DatePicker value → the { from, to } range the report pages query with.
|
||||
*
|
||||
* The shared DatePicker hands back one day ('YYYY-MM-DD') or a { from, to }
|
||||
* range (its presets, or rangeSelect). Reports always need a range, never
|
||||
* past today — they count orders by when they were CREATED, so a future day is
|
||||
* empty by definition — and never inverted.
|
||||
*/
|
||||
export const toReportRange = (value) => {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
const clamp = (d) => (d && d > today ? today : d);
|
||||
if (typeof value === 'string' && value !== 'all') return { from: clamp(value), to: clamp(value) };
|
||||
if (value && typeof value === 'object') {
|
||||
const from = clamp(value.from || value.to || today);
|
||||
const to = clamp(value.to || value.from || today);
|
||||
return from <= to ? { from, to } : { from: to, to: from };
|
||||
}
|
||||
return { from: today, to: today };
|
||||
};
|
||||
|
||||
@@ -3,14 +3,18 @@ import dayjs from 'dayjs';
|
||||
import { FileSpreadsheet, ListOrdered } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Stack, StatusBadge,
|
||||
SelectTrigger, SelectValue, Stack, StatusBadge, ZoneSelector,
|
||||
} from '@/components/ds';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useDeliveries, useRiderOptions, useTenants } from '@/lib/doormileHooks';
|
||||
import { useAppLocations, useDeliveries, useRiderOptions, useTenantLocations, useTenants } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
import OperationalReportCards from './OperationalReportCards';
|
||||
import { DateRangeFields } from '@/components/ds/DateRangeFields';
|
||||
import { DatePicker } from '@/components/ds/DatePicker';
|
||||
// Same picker as Orders and Deliveries (not the browser's native date input),
|
||||
// in two-click range mode; see toReportRange for the value → { from, to }.
|
||||
import { toReportRange } from '@/lib/dateRange';
|
||||
|
||||
/**
|
||||
* Orders details — the row-level export behind the summary.
|
||||
@@ -39,6 +43,47 @@ export default function OrdersDetails() {
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
|
||||
// Zone filter — the same zone button and matcher the Orders and Deliveries
|
||||
// pages use (ZoneContext), so a zone picked there is the zone here too.
|
||||
// matchesZone knows both shapes: an order row matches on its pickup hub,
|
||||
// a miler on their hub / city.
|
||||
const { matchesZone, selectedZoneId } = useZone();
|
||||
|
||||
// Picking a CLIENT narrows the milers to that client's cities too. A client
|
||||
// record carries no city of its own; its branches (tenant locations) do, and
|
||||
// the zone list maps a city name to the city id every miler carries
|
||||
// (applocationid). Before this, choosing "Doormile Coimbatore Logistics" left
|
||||
// Bangalore and Hyderabad milers in the list.
|
||||
const { data: tenantLocations = [] } = useTenantLocations(tenantId === ALL ? null : tenantId);
|
||||
const { data: appLocations = [] } = useAppLocations();
|
||||
const clientCityIds = useMemo(() => {
|
||||
if (tenantId === ALL) return null;
|
||||
const norm = (v) => String(v || '').trim().toLowerCase();
|
||||
const cities = new Set((tenantLocations || []).map((loc) => norm(loc.city)).filter(Boolean));
|
||||
const ids = (appLocations || [])
|
||||
.filter((loc) => loc.applocationid && cities.has(norm(loc.locationname)))
|
||||
.map((loc) => String(loc.applocationid));
|
||||
// Unknown city (no branches yet, or a city with no hub) → don't hide every
|
||||
// miler; fall back to the zone filter alone.
|
||||
return ids.length ? new Set(ids) : null;
|
||||
}, [tenantId, tenantLocations, appLocations]);
|
||||
|
||||
const zoneRiders = useMemo(
|
||||
() =>
|
||||
(riders || [])
|
||||
.filter(matchesZone)
|
||||
.filter((rider) => !clientCityIds || clientCityIds.has(String(rider.applocationid))),
|
||||
[riders, matchesZone, clientCityIds]
|
||||
);
|
||||
|
||||
// A miler picked under the previous zone may not ride in this one; keeping
|
||||
// them selected would silently empty the table.
|
||||
useEffect(() => {
|
||||
if (riderId !== ALL && !zoneRiders.some((rider) => String(rider.userid) === riderId)) {
|
||||
setRiderId(ALL);
|
||||
}
|
||||
}, [selectedZoneId, tenantId, zoneRiders, riderId]);
|
||||
|
||||
const { data, isLoading, isFetching } = useDeliveries({
|
||||
page,
|
||||
pageSize: 200,
|
||||
@@ -68,6 +113,7 @@ export default function OrdersDetails() {
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
collected
|
||||
.filter(matchesZone)
|
||||
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
|
||||
.filter((row) => riderId === ALL || String(row.userid) === riderId)
|
||||
.filter((row) =>
|
||||
@@ -77,7 +123,7 @@ export default function OrdersDetails() {
|
||||
debouncedSearch
|
||||
)
|
||||
),
|
||||
[collected, tenantId, riderId, debouncedSearch]
|
||||
[collected, matchesZone, tenantId, riderId, debouncedSearch]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
@@ -182,7 +228,15 @@ export default function OrdersDetails() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DateRangeFields value={range} onChange={setRange} />
|
||||
<DatePicker
|
||||
value={range}
|
||||
onChange={(v) => setRange(toReportRange(v))}
|
||||
allowAll={false}
|
||||
align="start"
|
||||
rangeSelect
|
||||
maxDate={dayjs().format('YYYY-MM-DD')}
|
||||
/>
|
||||
<ZoneSelector />
|
||||
<Select value={tenantId} onValueChange={setTenantId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All clients" />
|
||||
@@ -203,7 +257,7 @@ export default function OrdersDetails() {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All milers</SelectItem>
|
||||
{(riders || []).map((rider) => (
|
||||
{zoneRiders.map((rider) => (
|
||||
<SelectItem key={rider.userid} value={String(rider.userid)}>
|
||||
{rider.label}
|
||||
</SelectItem>
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
useAppLocations, useReportLocationSummary, useReportSummary, useTenantLocations, useTenants,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
import { DateRangeFields } from '@/components/ds/DateRangeFields';
|
||||
import { DatePicker } from '@/components/ds/DatePicker';
|
||||
// Same picker as Orders and Deliveries, in two-click range mode; see
|
||||
// toReportRange for how its value becomes the report's { from, to }.
|
||||
import { toReportRange } from '@/lib/dateRange';
|
||||
|
||||
/**
|
||||
* Orders summary — the same counts the Orders page shows, grouped.
|
||||
@@ -213,7 +216,14 @@ export default function OrdersSummary() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<DateRangeFields value={range} onChange={setRange} />
|
||||
<DatePicker
|
||||
value={range}
|
||||
onChange={(v) => setRange(toReportRange(v))}
|
||||
allowAll={false}
|
||||
align="start"
|
||||
rangeSelect
|
||||
maxDate={dayjs().format('YYYY-MM-DD')}
|
||||
/>
|
||||
<Select value={zone} onValueChange={setZone}>
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue placeholder="All zones" />
|
||||
|
||||
@@ -122,3 +122,72 @@ describe('DatePicker — the popover after a choice', () => {
|
||||
expect(screen.queryByText('This Month')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* rangeSelect (Orders Summary): the first calendar click used to commit a
|
||||
* single day and close the popover, so an operator could never pick a from
|
||||
* AND a to date. With rangeSelect the first click only marks the start.
|
||||
*/
|
||||
describe('DatePicker — rangeSelect', () => {
|
||||
// A month well in the past, so every day in it is selectable under maxDate.
|
||||
const month = today.subtract(2, 'month').startOf('month');
|
||||
const day = (n) => month.date(n).format('YYYY-MM-DD');
|
||||
const dayButton = (n) =>
|
||||
screen.getAllByRole('button', { name: String(n) }).find((b) => !b.className.includes('text-ink-4/40'));
|
||||
|
||||
const openRangePicker = (onChange, extra = {}) => {
|
||||
render(<DatePicker value={{ from: day(1), to: day(1) }} onChange={onChange} rangeSelect maxDate={todayStr} {...extra} />);
|
||||
openCalendar();
|
||||
};
|
||||
|
||||
it('keeps the calendar open after the first click and emits nothing yet', () => {
|
||||
const onChange = jest.fn();
|
||||
openRangePicker(onChange);
|
||||
fireEvent.click(dayButton(5));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(/now pick the end date/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('emits { from, to } on the second click and closes', () => {
|
||||
const onChange = jest.fn();
|
||||
openRangePicker(onChange);
|
||||
fireEvent.click(dayButton(5));
|
||||
fireEvent.click(dayButton(12));
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
expect(onChange).toHaveBeenCalledWith({ from: day(5), to: day(12) });
|
||||
expect(screen.queryByText(/pick the end date/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('orders the two clicks when the end is picked before the start', () => {
|
||||
const onChange = jest.fn();
|
||||
openRangePicker(onChange);
|
||||
fireEvent.click(dayButton(20));
|
||||
fireEvent.click(dayButton(3));
|
||||
expect(onChange).toHaveBeenCalledWith({ from: day(3), to: day(20) });
|
||||
});
|
||||
|
||||
it('greys out and ignores days after maxDate', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<DatePicker value={todayStr} onChange={onChange} rangeSelect maxDate={todayStr} />);
|
||||
openCalendar();
|
||||
const tomorrow = today.add(1, 'day');
|
||||
// Only assert when tomorrow is visible in this month's grid.
|
||||
const btn = screen
|
||||
.getAllByRole('button', { name: String(tomorrow.date()) })
|
||||
.find((b) => b.disabled);
|
||||
if (tomorrow.month() === today.month()) {
|
||||
expect(btn).toBeDefined();
|
||||
fireEvent.click(btn);
|
||||
fireEvent.click(btn);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the default (single-click) pickers unchanged', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<DatePicker value={todayStr} onChange={onChange} />);
|
||||
openCalendar();
|
||||
fireEvent.click(screen.getByText('Yesterday'));
|
||||
expect(onChange).toHaveBeenCalledWith(today.subtract(1, 'day').format('YYYY-MM-DD'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,10 +347,19 @@ describe('ZoneContext', () => {
|
||||
expect(result.current.matchesZone({ milerprofileid: 11, hubid: '2' })).toBe(true);
|
||||
});
|
||||
|
||||
it('should match a rider by their app location', () => {
|
||||
it('should match a rider by their app location (city id against city id)', () => {
|
||||
localStorage.setItem(ZONE_STORAGE_KEY, '2');
|
||||
const { result } = setup();
|
||||
expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '2' })).toBe(true);
|
||||
const { result } = setup({ hubs: [{ ...KORAMANGALA, applocationid: '3' }] });
|
||||
expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '3' })).toBe(true);
|
||||
});
|
||||
|
||||
// Regression: a rider's applocationid (a CITY id) was also compared with the
|
||||
// selected HUB id. Coimbatore Neptune Hub is hubid 2 in city 1, Hyderabad is
|
||||
// city 2 — so every Hyderabad rider matched the Coimbatore zone.
|
||||
it('should not match a rider whose city id merely equals the hub id', () => {
|
||||
localStorage.setItem(ZONE_STORAGE_KEY, '2');
|
||||
const { result } = setup({ hubs: [{ ...KORAMANGALA, applocationid: '1' }] });
|
||||
expect(result.current.matchesZone({ milerprofileid: 11, applocationid: '2', hubid: null })).toBe(false);
|
||||
});
|
||||
|
||||
it('should match a rider by city when they carry no hub id', () => {
|
||||
|
||||
@@ -52,3 +52,33 @@ describe('toDateRange', () => {
|
||||
expect(toDateRange(true)).toEqual({ from: '', to: '' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toReportRange — DatePicker value → report { from, to }', () => {
|
||||
const { toReportRange } = require('@/lib/dateRange');
|
||||
const dayjsLib = require('dayjs');
|
||||
const todayStr = dayjsLib().format('YYYY-MM-DD');
|
||||
const past = (n) => dayjsLib().subtract(n, 'day').format('YYYY-MM-DD');
|
||||
const future = (n) => dayjsLib().add(n, 'day').format('YYYY-MM-DD');
|
||||
|
||||
it('turns a single day into a one-day range', () => {
|
||||
expect(toReportRange(past(3))).toEqual({ from: past(3), to: past(3) });
|
||||
});
|
||||
|
||||
it('keeps a valid range as it is', () => {
|
||||
expect(toReportRange({ from: past(7), to: past(1) })).toEqual({ from: past(7), to: past(1) });
|
||||
});
|
||||
|
||||
it('puts an inverted range in order', () => {
|
||||
expect(toReportRange({ from: past(1), to: past(7) })).toEqual({ from: past(7), to: past(1) });
|
||||
});
|
||||
|
||||
it('never goes past today', () => {
|
||||
expect(toReportRange(future(2))).toEqual({ from: todayStr, to: todayStr });
|
||||
expect(toReportRange({ from: past(2), to: future(5) })).toEqual({ from: past(2), to: todayStr });
|
||||
});
|
||||
|
||||
it('falls back to today for "all" or nothing', () => {
|
||||
expect(toReportRange('all')).toEqual({ from: todayStr, to: todayStr });
|
||||
expect(toReportRange(undefined)).toEqual({ from: todayStr, to: todayStr });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user