updates on the zone selector for the specific login
This commit is contained in:
@@ -3,7 +3,7 @@ import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from
|
||||
import {
|
||||
Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input,
|
||||
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
|
||||
Stack, StatusBadge, Surface, Tabs, Textarea,
|
||||
Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants,
|
||||
useUpdateDeliveryStatus,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
|
||||
import { formatDoormileTimestamp, parseDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
@@ -93,6 +94,7 @@ export default function Deliveries() {
|
||||
const updateStatus = useUpdateDeliveryStatus();
|
||||
const cancelDelivery = useCancelDelivery();
|
||||
const notifyMiler = useNotifyMiler();
|
||||
const { selectedZone, isAllZones, matchesZone } = useZone();
|
||||
|
||||
const allRows = useMemo(() => {
|
||||
return [...(data?.rows || [])].sort((a, b) => {
|
||||
@@ -102,20 +104,17 @@ export default function Deliveries() {
|
||||
});
|
||||
}, [data?.rows]);
|
||||
|
||||
/* Batch, client and rider first, then status, then search — the counts under
|
||||
/* Zone, Batch, client and rider first, then status, then search — the counts under
|
||||
the tabs have to describe the slice the operator has chosen, not the whole
|
||||
day, or a tab reads 6 and shows 2.
|
||||
|
||||
There is deliberately no site filter: a delivery row carries its client but
|
||||
no tenant-location id, so the only honest filter at that level would be one
|
||||
that silently matched nothing. */
|
||||
day, or a tab reads 6 and shows 2. */
|
||||
const batchRows = useMemo(
|
||||
() =>
|
||||
allRows
|
||||
.filter(matchesZone)
|
||||
.filter((row) => batch === 'all' || getRowBatchId(row) === batch)
|
||||
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
|
||||
.filter((row) => riderId === ALL || String(row.userid) === riderId),
|
||||
[allRows, batch, tenantId, riderId]
|
||||
[allRows, matchesZone, batch, tenantId, riderId]
|
||||
);
|
||||
|
||||
const unmapped = useMemo(
|
||||
@@ -399,6 +398,7 @@ export default function Deliveries() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<ZoneSelector className="w-full sm:w-auto" />
|
||||
<Select value={batch} onValueChange={setBatch}>
|
||||
<SelectTrigger className="w-full sm:w-52">
|
||||
<SelectValue />
|
||||
|
||||
@@ -2,9 +2,10 @@ import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { motion } from 'framer-motion';
|
||||
import dayjs from 'dayjs';
|
||||
import { Ban, FileSpreadsheet, Package, Plus, Truck, Upload } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs } from '@/components/ds';
|
||||
import { Ban, FileSpreadsheet, MapPin, Package, Plus, Truck, Upload } from 'lucide-react';
|
||||
import { Button, ConfirmModal, DataTable, EmptyState, PageHeader, SearchInput, Stack, StatusBadge, Tabs, ZoneSelector } from '@/components/ds';
|
||||
import { useBulkCancelBookings, useCustomers } from '@/lib/doormileHooks';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
import { createAutomationDeliveries } from '@/api/doormile';
|
||||
import { drainBookings } from '@/lib/assistant/scan';
|
||||
import { OpenToast, messageOf } from '@/api/doormile/notify';
|
||||
@@ -68,20 +69,13 @@ export default function Orders() {
|
||||
const [selectedIds, setSelectedIds] = useState([]);
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
|
||||
const { selectedZone, selectedZoneId, isAllZones, matchesZone } = useZone();
|
||||
|
||||
const { data: customers = [] } = useCustomers();
|
||||
const bulkCancel = useBulkCancelBookings();
|
||||
|
||||
/* Polled: an operator watching the pending queue needs new bookings to
|
||||
appear without reaching for refresh. */
|
||||
/* `getBookings(1, 1000)` used to back this screen. That is page ONE at the
|
||||
API's hard cap, and it discards the envelope's `total`, so past 1000
|
||||
lifetime bookings every tab here was computed from a partial list with no
|
||||
way to know it. `/admin/bookings` documents no row order either (scan.js
|
||||
detects it per call rather than assuming), so a freshly created order is
|
||||
not guaranteed to be on page 1 — it could simply be absent from every tab.
|
||||
Drain the pages instead, and surface `truncated` below rather than quietly
|
||||
showing a short list. `cached: false` because TanStack owns freshness for
|
||||
this screen; scan.js's own 20s page cache would cap the refetch interval. */
|
||||
const {
|
||||
data: scan = EMPTY_SCAN,
|
||||
isLoading,
|
||||
@@ -104,6 +98,10 @@ export default function Orders() {
|
||||
});
|
||||
}, [bookings]);
|
||||
|
||||
const zoneBookings = useMemo(() => {
|
||||
return sortedBookings.filter(matchesZone);
|
||||
}, [sortedBookings, matchesZone]);
|
||||
|
||||
const customerMap = useMemo(() => {
|
||||
const map = new Map();
|
||||
(customers || []).forEach((customer) => {
|
||||
@@ -115,7 +113,7 @@ export default function Orders() {
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const byStatus = {};
|
||||
(sortedBookings || []).forEach((booking) => {
|
||||
(zoneBookings || []).forEach((booking) => {
|
||||
const status = String(booking.status || '').toLowerCase();
|
||||
byStatus[status] = (byStatus[status] || 0) + 1;
|
||||
});
|
||||
@@ -125,25 +123,25 @@ export default function Orders() {
|
||||
statusesInGroup(group).reduce((sum, status) => sum + (byStatus[status] || 0), 0),
|
||||
])
|
||||
);
|
||||
}, [sortedBookings]);
|
||||
}, [zoneBookings]);
|
||||
|
||||
const tabStatuses = useMemo(() => statusesInGroup(tab), [tab]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
(sortedBookings || [])
|
||||
(zoneBookings || [])
|
||||
.filter((booking) => tabStatuses.includes(String(booking.status || '').toLowerCase()))
|
||||
.filter((booking) =>
|
||||
matchesQuery(booking, ['bookingno', 'pickupaddress', 'deliveryaddress', 'notes'], debouncedSearch)
|
||||
),
|
||||
[sortedBookings, tabStatuses, debouncedSearch]
|
||||
[zoneBookings, tabStatuses, debouncedSearch]
|
||||
);
|
||||
|
||||
/* A row that scrolled out of the current tab must not stay silently selected
|
||||
— the assign buttons act on the selection, not on what is on screen. */
|
||||
useEffect(() => {
|
||||
setSelectedIds([]);
|
||||
}, [tab]);
|
||||
}, [tab, selectedZoneId]);
|
||||
|
||||
const selectedOrders = useMemo(
|
||||
() => rows.filter((row) => selectedIds.includes(row.bookingid)),
|
||||
@@ -168,6 +166,8 @@ export default function Orders() {
|
||||
selectedMode: { value: 1 },
|
||||
deliveryData: variables?.deliveries || [],
|
||||
startdate: dayjs().format('YYYY-MM-DD'),
|
||||
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
|
||||
zoneName: selectedZone.hubname,
|
||||
},
|
||||
});
|
||||
},
|
||||
@@ -176,6 +176,11 @@ export default function Orders() {
|
||||
|
||||
const assignOrders = () => {
|
||||
if (!selectedOrders.length) return;
|
||||
if (isAllZones) {
|
||||
OpenToast('Please select a specific Zone / Hub before assigning orders.', 'warning', 4000);
|
||||
return;
|
||||
}
|
||||
|
||||
const deliveries = selectedOrders.map((booking) => {
|
||||
const charge = chargeOf(booking);
|
||||
return {
|
||||
@@ -195,6 +200,7 @@ export default function Orders() {
|
||||
deliverycharges: charge,
|
||||
pickuplocation: booking.pickupaddress,
|
||||
deliverylocation: booking.deliveryaddress,
|
||||
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : booking.hubid,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -203,6 +209,7 @@ export default function Orders() {
|
||||
selectedMode: { value: 1 },
|
||||
hypertuning_params: 'balanced',
|
||||
absent_riders: [],
|
||||
hubid: selectedZone.hubid !== 'all' ? selectedZone.hubid : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -338,11 +345,12 @@ export default function Orders() {
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
|
||||
<ZoneSelector />
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by order number, address or note"
|
||||
className="w-full sm:w-72"
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
{selectedIds.length > 0 && tab === 'pending' && (
|
||||
<Button variant="outline" onClick={() => setCancelOpen(true)}>
|
||||
@@ -359,21 +367,43 @@ export default function Orders() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-caption text-ink-3">
|
||||
{rows.length === (counts[tab] ?? 0)
|
||||
? `${counts[tab] ?? 0} orders`
|
||||
: `${rows.length} of ${counts[tab] ?? 0} orders`}
|
||||
{/* A capped scan means an order really can be missing from this tab.
|
||||
Say so — a silently short list is what sent operators hunting for
|
||||
an order they had just created. */}
|
||||
{scan.truncated && (
|
||||
<span className="text-warning">
|
||||
{` — showing the first ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings, so this count is a floor. Search by order number to find a specific order.`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{tab === 'pending' && isAllZones && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50/80 p-3 text-body-sm text-amber-900 shadow-2xs">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="grid h-8 w-8 shrink-0 place-items-center rounded-lg bg-amber-100 text-amber-800">
|
||||
<MapPin className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-bold text-amber-950">Select an Operational Zone for Dispatch Assignment</p>
|
||||
<p className="text-[11px] text-amber-800 truncate sm:whitespace-normal">
|
||||
To select and assign pending orders to riders, please choose a specific Hub/Zone (e.g. Coimbatore Neptune Hub) first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ZoneSelector width="w-64" className="border-amber-300 bg-white" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-caption text-ink-3">
|
||||
{rows.length === (counts[tab] ?? 0)
|
||||
? `${counts[tab] ?? 0} orders`
|
||||
: `${rows.length} of ${counts[tab] ?? 0} orders`}
|
||||
{!isAllZones && (
|
||||
<span className="font-medium text-ink-2"> · Zone: {selectedZone.hubname}</span>
|
||||
)}
|
||||
{/* A capped scan means an order really can be missing from this tab.
|
||||
Say so — a silently short list is what sent operators hunting for
|
||||
an order they had just created. */}
|
||||
{scan.truncated && (
|
||||
<span className="text-warning">
|
||||
{` — showing the first ${scan.scanned.toLocaleString('en-IN')} of ${scan.total.toLocaleString(
|
||||
'en-IN'
|
||||
)} bookings, so this count is a floor. Search by order number to find a specific order.`}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
@@ -381,8 +411,8 @@ export default function Orders() {
|
||||
getRowId={(row) => row.bookingid}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
selectedIds={tab === 'pending' ? selectedIds : undefined}
|
||||
onSelectionChange={tab === 'pending' ? setSelectedIds : undefined}
|
||||
selectedIds={tab === 'pending' && !isAllZones ? selectedIds : undefined}
|
||||
onSelectionChange={tab === 'pending' && !isAllZones ? setSelectedIds : undefined}
|
||||
pageSize={25}
|
||||
/* No defaultSort: `sortedBookings` above already orders these newest
|
||||
first by `bookingid`, and DataTable leaves rows untouched when it
|
||||
|
||||
@@ -7,12 +7,13 @@ import {
|
||||
import {
|
||||
Avatar, Button, ConfirmModal, DataTable, Drawer, EmptyState, Field, Grid, IconButton,
|
||||
Input, KpiCard, Modal, PageHeader, SearchInput, Select, SelectContent, SelectItem, SelectTrigger,
|
||||
SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea,
|
||||
SelectValue, Stack, StatusBadge, Surface, Tabs, Textarea, ZoneSelector,
|
||||
} from '@/components/ds';
|
||||
import {
|
||||
useAssignMilerVehicle, useBlockMiler, useMilerLogs, useMilers, useNotifyMiler,
|
||||
useRiderSummaryCounts, useVehicles,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { useZone } from '@/lib/ZoneContext';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
@@ -64,17 +65,23 @@ export default function Riders() {
|
||||
const assignVehicle = useAssignMilerVehicle();
|
||||
const notifyMiler = useNotifyMiler();
|
||||
|
||||
const { selectedZone, isAllZones, matchesZone } = useZone();
|
||||
|
||||
const zoneRiders = useMemo(() => {
|
||||
return (riders || []).filter(matchesZone);
|
||||
}, [riders, matchesZone]);
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const list = riders || [];
|
||||
const list = zoneRiders;
|
||||
return {
|
||||
all: list.length,
|
||||
active: list.filter(isActive).length,
|
||||
blocked: list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked').length,
|
||||
};
|
||||
}, [riders]);
|
||||
}, [zoneRiders]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = riders || [];
|
||||
let list = zoneRiders;
|
||||
if (tab === 'active') list = list.filter(isActive);
|
||||
else if (tab === 'blocked') {
|
||||
list = list.filter((rider) => String(rider.availabilitystatus || '').toLowerCase() === 'blocked');
|
||||
@@ -82,7 +89,7 @@ export default function Riders() {
|
||||
return list.filter((rider) =>
|
||||
matchesQuery(rider, [riderName, 'phone', 'userid', 'city', 'suburb', 'defaultvehicletype'], debouncedSearch)
|
||||
);
|
||||
}, [riders, tab, debouncedSearch]);
|
||||
}, [zoneRiders, tab, debouncedSearch]);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
@@ -218,7 +225,7 @@ export default function Riders() {
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={summary?.available ?? 0}
|
||||
value={summary?.available ?? counts.active}
|
||||
label="Available now"
|
||||
tone="success"
|
||||
index={1}
|
||||
@@ -226,7 +233,14 @@ export default function Riders() {
|
||||
/>
|
||||
<KpiCard
|
||||
icon={Bike}
|
||||
value={summary?.onDelivery ?? 0}
|
||||
value={
|
||||
summary?.onDelivery ??
|
||||
zoneRiders.filter(
|
||||
(r) =>
|
||||
String(r.availabilitystatus || '').toLowerCase() === 'on_delivery' ||
|
||||
String(r.availabilitystatus || '').toLowerCase() === 'ondelivery'
|
||||
).length
|
||||
}
|
||||
label="On delivery"
|
||||
tone="accent"
|
||||
index={2}
|
||||
@@ -234,7 +248,7 @@ export default function Riders() {
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CircleSlash}
|
||||
value={summary?.inactive ?? 0}
|
||||
value={summary?.inactive ?? counts.blocked}
|
||||
label="Offline or blocked"
|
||||
tone="neutral"
|
||||
index={3}
|
||||
@@ -256,12 +270,13 @@ export default function Riders() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 items-center justify-end gap-2 sm:flex-none">
|
||||
<div className="flex min-w-0 flex-1 flex-wrap items-center justify-end gap-2 sm:flex-none">
|
||||
<ZoneSelector />
|
||||
<SearchInput
|
||||
value={search}
|
||||
onChange={setSearch}
|
||||
placeholder="Search by name, phone, id or city"
|
||||
className="w-full sm:w-72"
|
||||
className="w-full sm:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user