updates on the minor changes like zone updates and datepicker fix

This commit is contained in:
2026-09-28 15:36:59 +05:30
parent 3bde09839c
commit 5bf0104b61
8 changed files with 284 additions and 22 deletions

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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" />

View File

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

View File

@@ -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', () => {

View File

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