updates on the ui changes and changed into doormile

This commit is contained in:
2026-08-24 18:10:47 +05:30
parent dcf9770ada
commit aa42d9ee81
409 changed files with 49950 additions and 66532 deletions

View File

@@ -0,0 +1,693 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Ban, Bell, Bike, FileSpreadsheet, Package, PackageSearch, Truck } from 'lucide-react';
import {
Alert, Button, DataTable, Drawer, EmptyState, Field, IconButton, Input,
PageHeader, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Modal,
Stack, StatusBadge, Surface, Tabs, Textarea,
} from '@/components/ds';
import { ListToolbar } from '@/components/doormile/ListToolbar';
import {
useBooking, useCancelDelivery, useChangeDeliveryRider, useDeliveries,
useDeliveryCounts, useNotifyMiler, useRiderOptions, useTenants,
useUpdateDeliveryStatus,
} from '@/lib/doormileHooks';
import { BATCHES, batchRangeLabel, getRowBatchId } from '@/lib/batchBucket';
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
/**
* Deliveries — orders that have moved past merely being created.
*
* The rows are built from `/admin/bookings` filtered to those with a rider or a
* consignment, joined against customers, riders, clients and consignments; see
* `fetchDeliveries` for why the consignments endpoint cannot be the source on
* its own, and for which columns are deliberately left blank rather than filled
* with numbers the API never returned.
*
* A row's status comes from its consignment when it has one. A booking freezes
* at `Converted_To_Consignment` the moment it is picked up, so a row can
* legitimately read "Active" while the booking still says otherwise — which is
* why the table can say where the value came from.
*/
/* `other` is not declared here — it is appended only when rows actually land
in it, so the strip never carries a permanently empty tab. */
const STATUS_TABS = [
{ value: 'pending', label: 'Pending', countKey: 'uncoveredLength' },
{ value: 'accepted', label: 'Accepted', countKey: 'assignedLength' },
{ value: 'arrived', label: 'Arrived', countKey: 'arrivedLength' },
{ value: 'picked', label: 'Picked', countKey: 'pickedLength' },
{ value: 'active', label: 'Active', countKey: 'activeLength' },
{ value: 'skipped', label: 'Skipped', countKey: 'skippedLength' },
{ value: 'delivered', label: 'Delivered', countKey: 'coveredLength' },
{ value: 'cancelled', label: 'Cancelled', countKey: 'cancelLength' },
];
const KNOWN_STATUSES = STATUS_TABS.map((tab) => tab.value).concat('canceled');
const BATCH_OPTIONS = [
{ id: 'all', label: 'All batches', range: 'Across the day' },
...BATCHES.map((batch) => ({ id: batch.id, label: batch.label, range: batchRangeLabel(batch) })),
];
const ALL = 'all';
/** The statuses this console can actually write to a consignment. */
const SETTABLE_STATUSES = [
{ value: 'active', label: 'Out for delivery' },
{ value: 'delivered', label: 'Delivered' },
{ value: 'cancelled', label: 'Cancelled' },
];
export default function Deliveries() {
// Unscoped by default. `fetchDeliveries` treats a blank from/to as "no
// bound" (see queries.js — `if (!startdate && !enddate) return true`), and
// it filters on `orderdate` (the booking's creation day), never on when a
// row was last touched. Defaulting this range to today meant the Pending/
// Accepted/Arrived/Picked/Active tabs — an outstanding-work QUEUE, not a
// day's log — only showed a booking that was also *created* today.
// Reassign an order that came in yesterday and it's still correctly
// "Pending" work, but it dropped off the board the moment you did it,
// reading as "I just assigned it and it's not there." Same "state vs
// flow" distinction this app already draws elsewhere for orders. Delivered/
// Cancelled stay meaningful to scope by date — the operator narrows via
// the date inputs same as before.
const [range, setRange] = useState({ from: '', to: '' });
const [tab, setTab] = useState('pending');
// 'all' rather than the wave matching the current clock hour: a batch is a
// slice of ONE day, so "jump to the slot matching right now" only means
// something once the date range above is scoped to a single day. With the
// range unscoped by default, defaulting the batch too would silently hide
// almost everything — a booking created three days ago at 11am has no
// relationship to "the slot matching right now" on a different day.
const [batch, setBatch] = useState('all');
const [search, setSearch] = useState('');
const debouncedSearch = useDebouncedValue(search);
const [page, setPage] = useState(1);
const [tenantId, setTenantId] = useState(ALL);
const [riderId, setRiderId] = useState(ALL);
const [detailRow, setDetailRow] = useState(null);
const [riderRow, setRiderRow] = useState(null);
const [selectedRider, setSelectedRider] = useState('');
const [statusRow, setStatusRow] = useState(null);
const [nextStatus, setNextStatus] = useState('delivered');
const [cancelRow, setCancelRow] = useState(null);
const [cancelReason, setCancelReason] = useState('');
const { data, isLoading, isFetching } = useDeliveries({
page,
pageSize: 100,
from: range.from,
to: range.to,
});
const { data: counts } = useDeliveryCounts();
const { data: riders = [] } = useRiderOptions();
const { data: tenants = [] } = useTenants();
const changeRider = useChangeDeliveryRider();
const updateStatus = useUpdateDeliveryStatus();
const cancelDelivery = useCancelDelivery();
const notifyMiler = useNotifyMiler();
const allRows = data?.rows || [];
/* 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. */
const batchRows = useMemo(
() =>
allRows
.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]
);
const unmapped = useMemo(
() => batchRows.filter((row) => !KNOWN_STATUSES.includes(String(row.orderstatus || '').toLowerCase())),
[batchRows]
);
const tabs = useMemo(() => {
const base = STATUS_TABS.map((entry) => ({
value: entry.value,
label: entry.label,
count: batchRows.filter((row) => String(row.orderstatus || '').toLowerCase() === entry.value).length,
}));
/* A row whose status matches no tab used to be counted nowhere and shown
nowhere. It gets its own tab, but only when one actually exists. */
if (unmapped.length) base.push({ value: 'other', label: 'Other', count: unmapped.length });
return base;
}, [batchRows, unmapped]);
const rows = useMemo(() => {
const scoped =
tab === 'other'
? unmapped
: batchRows.filter((row) => {
const status = String(row.orderstatus || '').toLowerCase();
return tab === 'cancelled' ? status === 'cancelled' || status === 'canceled' : status === tab;
});
return scoped.filter((row) =>
matchesQuery(
row,
['orderid', 'deliverycustomer', 'ridername', 'pickupaddress', 'deliveryaddress', 'tenantname'],
debouncedSearch
)
);
}, [batchRows, tab, unmapped, debouncedSearch]);
/* Whether anything is narrowing the list — drives the empty state's wording
and whether Clear filters is offered. It has to name every filter, or the
button appears and then visibly fails to clear one of them. */
const isFiltered =
Boolean(debouncedSearch) || batch !== 'all' || tenantId !== ALL || riderId !== ALL;
/* A new filter re-scopes the list, so page 1 is the only page that still
means anything. */
useEffect(() => {
setPage(1);
}, [tab, batch, tenantId, riderId, debouncedSearch, range.from, range.to]);
const columns = useMemo(
() => [
{
key: 'orderid',
header: 'Order & client',
sortable: true,
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm font-semibold text-ink-1">{orDash(row.orderid)}</p>
<p className="truncate text-caption text-ink-3">{orDash(row.tenantname)}</p>
</div>
),
},
{
key: 'route',
header: 'Route (pickup → drop)',
sortValue: (row) => row.pickupaddress || '',
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
<span className="font-medium text-ink-1">Pickup</span>
{row.pickupaddress ? ` · ${row.pickupaddress}` : ' · —'}
</p>
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
<span className="font-medium text-ink-2">{orDash(row.deliverycustomer)}</span>
{row.deliveryaddress ? ` · ${row.deliveryaddress}` : ''}
</p>
</div>
),
},
{
key: 'ridername',
header: 'Rider',
sortable: true,
cell: (row) => (
<div className="min-w-0">
<p className="truncate text-body-sm text-ink-1">{orDash(row.ridername)}</p>
<p className="text-caption text-ink-3">{orDash(row.ridercontact)}</p>
</div>
),
hideBelow: 'md',
},
{
key: 'eta',
header: 'ETA & distance',
sortValue: (row) => row.expecteddeliverytime || '',
cell: (row) => (
<div className="min-w-0">
<p className="text-body-sm text-ink-2">
{formatDoormileTimestamp(row.expecteddeliverytime, 'hh:mm A')}
</p>
<p className="text-caption text-ink-3">{formatKm(row.kms)}</p>
</div>
),
hideBelow: 'lg',
},
{
key: 'deliverycharges',
header: 'Amount',
align: 'right',
sortValue: (row) => Number(row.deliverycharges) || 0,
accessor: (row) => currency(row.deliverycharges),
hideBelow: 'lg',
},
{
key: 'orderstatus',
header: 'Status',
sortable: true,
cell: (row) => (
<div className="flex flex-col items-start gap-0.5">
<StatusBadge status={row.orderstatus} dot size="sm" />
{/* Where the value came from. Without this, a row reading "Active"
while the booking still says Converted_To_Consignment looks
exactly like a bug. */}
{row.statusfromconsignment ? (
<span className="text-[10px] text-ink-4">from consignment</span>
) : null}
</div>
),
},
{
key: 'actions',
header: 'Actions',
align: 'right',
cell: (row) => (
<div className="flex justify-end gap-1">
<IconButton
label="Order details"
icon={PackageSearch}
variant="ghost"
size="sm"
onClick={() => setDetailRow(row)}
/>
{row.orderstatus !== 'delivered' && row.milerprofileid ? (
<IconButton
label="Notify rider"
icon={Bell}
variant="ghost"
size="sm"
onClick={() =>
notifyMiler.mutate({
milerProfileId: row.milerprofileid,
title: 'DoormileXpress',
message: 'Orders have been placed for delivery. Kindly accept and process deliveries.',
})
}
/>
) : null}
{['pending', 'accepted', 'arrived'].includes(row.orderstatus) ? (
<IconButton
label="Change rider"
icon={Bike}
variant="ghost"
size="sm"
onClick={() => {
setRiderRow(row);
setSelectedRider('');
}}
/>
) : null}
<IconButton
label="Update status"
icon={Truck}
variant="ghost"
size="sm"
onClick={() => {
setStatusRow(row);
setNextStatus('delivered');
}}
/>
{!['delivered', 'cancelled', 'canceled'].includes(String(row.orderstatus || '').toLowerCase()) ? (
<IconButton
label="Cancel delivery"
icon={Ban}
variant="ghost"
size="sm"
onClick={() => {
setCancelRow(row);
setCancelReason('');
}}
/>
) : null}
</div>
),
},
],
[notifyMiler]
);
const exportColumns = [
{ key: 'orderid', header: 'Order' },
{ key: 'tenantname', header: 'Client' },
{ key: 'deliverycustomer', header: 'Customer' },
{ key: 'ridername', header: 'Rider' },
{ key: 'orderstatus', header: 'Status' },
{ key: 'kms', header: 'Km' },
{ key: 'deliverycharges', header: 'Amount' },
{ key: 'orderdate', header: 'Created' },
];
return (
<Stack space="lg">
<PageHeader
icon={Truck}
title="Deliveries"
subtitle={`${counts?.total ?? 0} dispatched · ${
!range.from && !range.to
? 'All dates'
: range.from === range.to
? range.from
: `${range.from || '…'} → ${range.to || '…'}`
}`}
actions={
<>
<Input
type="date"
value={range.from}
onChange={(e) => setRange((current) => ({ ...current, from: e.target.value }))}
aria-label="From date"
className="w-40"
/>
<Input
type="date"
value={range.to}
onChange={(e) => setRange((current) => ({ ...current, to: e.target.value }))}
aria-label="To date"
className="w-40"
/>
</>
}
/>
<Stack space="sm">
<Tabs tabs={tabs} value={tab} onChange={setTab} />
<ListToolbar
search={search}
onSearchChange={setSearch}
placeholder="Search by order, customer, rider or address"
count={{ shown: rows.length, total: batchRows.length, noun: 'deliveries' }}
actions={
<Button
variant="ghost"
onClick={() => exportRows(rows, exportColumns, `deliveries-${tab}-${range.from || 'all'}`)}
disabled={rows.length === 0}
>
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
</Button>
}
>
<Select value={batch} onValueChange={setBatch}>
<SelectTrigger className="w-full sm:w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
{BATCH_OPTIONS.map((option) => (
<SelectItem key={option.id} value={option.id}>
{option.label} · {option.range}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={tenantId} onValueChange={setTenantId}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="All clients" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All clients</SelectItem>
{(tenants || []).map((tenant) => (
<SelectItem key={tenant.tenantid} value={String(tenant.tenantid)}>
{tenant.tenantname || `Tenant #${tenant.tenantid}`}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={riderId} onValueChange={setRiderId}>
<SelectTrigger className="w-full sm:w-48">
<SelectValue placeholder="All riders" />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL}>All riders</SelectItem>
{(riders || []).map((rider) => (
<SelectItem key={rider.userid} value={String(rider.userid)}>
{rider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</ListToolbar>
<DataTable
columns={columns}
rows={rows}
getRowId={(row) => row.orderheaderid}
loading={isLoading}
refreshing={isFetching && !isLoading}
pageSize={25}
defaultSort={{ key: 'orderid', direction: 'desc' }}
isFiltered={isFiltered}
onClearFilters={() => {
setSearch('');
setBatch('all');
setTenantId(ALL);
setRiderId(ALL);
}}
emptyState={
isFiltered ? undefined : (
<EmptyState
icon={Package}
title="Nothing dispatched here"
description="A booking appears once it has a rider or has become a consignment."
/>
)
}
/>
{data?.nextPage ? (
<div className="flex justify-center">
<Button variant="outline" onClick={() => setPage(data.nextPage)} loading={isFetching}>
Load more
</Button>
</div>
) : null}
</Stack>
<Modal
open={Boolean(riderRow)}
onOpenChange={(open) => !open && setRiderRow(null)}
title="Change rider"
description={riderRow ? `Order ${riderRow.orderid}` : undefined}
icon={Bike}
busy={changeRider.isPending}
footer={
<>
<Button variant="ghost" onClick={() => setRiderRow(null)}>
Cancel
</Button>
<Button
onClick={() => {
const rider = (riders || []).find((entry) => String(entry.userid) === selectedRider);
if (!rider) return;
changeRider.mutate(
{ rider, row: riderRow },
{ onSuccess: (res) => res?.success !== false && setRiderRow(null) }
);
}}
disabled={!selectedRider || changeRider.isPending}
>
Reassign
</Button>
</>
}
>
<Field label="Rider">
<Select value={selectedRider} onValueChange={setSelectedRider}>
<SelectTrigger>
<SelectValue placeholder="Choose a rider" />
</SelectTrigger>
<SelectContent>
{(riders || []).map((rider) => (
<SelectItem key={rider.userid} value={String(rider.userid)}>
{rider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</Modal>
<Modal
open={Boolean(statusRow)}
onOpenChange={(open) => !open && setStatusRow(null)}
title="Update status"
description={statusRow ? `Order ${statusRow.orderid}` : undefined}
icon={Truck}
busy={updateStatus.isPending}
footer={
<>
<Button variant="ghost" onClick={() => setStatusRow(null)}>
Cancel
</Button>
<Button
onClick={() =>
updateStatus.mutate(
{ ...statusRow, orderstatus: nextStatus },
{ onSuccess: (res) => res?.success !== false && setStatusRow(null) }
)
}
disabled={updateStatus.isPending || !statusRow?.consignmentid}
>
Update status
</Button>
</>
}
>
<Stack space="default">
{statusRow && !statusRow.consignmentid ? (
/* The write is consignment-scoped and only exists once the rider
marks pickup complete. Blocking here with the reason beats
letting the submit silently do nothing. */
<Alert tone="warning" title="Not picked up yet">
Status can only be updated once this order becomes a consignment — that happens when the rider completes
pickup.
</Alert>
) : null}
<Field
label="New status"
hint="Pending, Accepted, Arrived, Picked and Skipped have no equivalent on the consignment API and cannot be set from here."
>
<Select value={nextStatus} onValueChange={setNextStatus} disabled={!statusRow?.consignmentid}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SETTABLE_STATUSES.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</Stack>
</Modal>
<Modal
open={Boolean(cancelRow)}
onOpenChange={(open) => !open && setCancelRow(null)}
title="Cancel this delivery"
description={cancelRow ? `Order ${cancelRow.orderid}` : undefined}
busy={cancelDelivery.isPending}
footer={
<>
<Button variant="ghost" onClick={() => setCancelRow(null)}>
Keep it
</Button>
<Button
variant="destructive"
onClick={() =>
cancelDelivery.mutate(
{ row: cancelRow, reason: cancelReason },
{ onSettled: () => setCancelRow(null) }
)
}
loading={cancelDelivery.isPending}
>
Cancel delivery
</Button>
</>
}
>
<Field label="Reason">
<Textarea rows={3} value={cancelReason} onChange={(e) => setCancelReason(e.target.value)} />
</Field>
</Modal>
<OrderDetailDrawer row={detailRow} onClose={() => setDetailRow(null)} />
</Stack>
);
}
/**
* What is actually in the parcel.
*
* The table row is built from the bulk bookings feed, which carries no parcel
* contents — so this fetches the single booking on open rather than showing a
* panel of blanks. A parcel has no quantity field: N items arrive as N entries,
* which is why the count here is the length of the list.
*/
function OrderDetailDrawer({ row, onClose }) {
const { data: booking, isLoading } = useBooking(row?.orderheaderid);
const parcels = booking?.parcels || [];
return (
<Drawer
open={Boolean(row)}
onOpenChange={(next) => !next && onClose()}
title={`Order ${row?.orderid ?? ''}`}
description={row?.tenantname || undefined}
icon={PackageSearch}
size="lg"
>
{isLoading ? (
<p className="text-body-sm text-ink-3">Loading order…</p>
) : (
<Stack space="default">
<Surface variant="subtle" padding="sm" radius="lg">
<dl className="grid grid-cols-2 gap-3">
{[
['Status', row?.orderstatus],
['Rider', row?.ridername],
['Customer', row?.deliverycustomer],
['Contact', row?.deliverycontactno],
['Charge', currency(row?.deliverycharges)],
['Distance', formatKm(row?.kms)],
['Placed', formatDoormileTimestamp(row?.orderdate)],
['Expected', formatDoormileTimestamp(row?.expecteddeliverytime)],
].map(([label, value]) => (
<div key={label}>
<dt className="text-caption uppercase tracking-wide text-ink-3">{label}</dt>
<dd className="text-body-sm text-ink-1">{orDash(value)}</dd>
</div>
))}
</dl>
</Surface>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Pickup</p>
<p className="text-body-sm text-ink-2">{orDash(row?.pickupaddress)}</p>
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Drop</p>
<p className="text-body-sm text-ink-2">{orDash(row?.deliveryaddress)}</p>
</div>
<div>
<p className="mb-2 text-overline uppercase text-ink-4">
Parcels {parcels.length ? `· ${parcels.length}` : ''}
</p>
{parcels.length === 0 ? (
<p className="text-body-sm text-ink-3">No parcel detail recorded on this order.</p>
) : (
<Stack space="xs">
{parcels.map((parcel, index) => (
<Surface key={index} variant="subtle" padding="sm" radius="lg">
<p className="text-body-sm font-medium text-ink-1">{orDash(parcel.itemcategory)}</p>
<p className="text-caption text-ink-3">
{orDash(parcel.itemdescription)}
{parcel.declaredvalue ? ` · ${currency(parcel.declaredvalue)}` : ''}
</p>
</Surface>
))}
</Stack>
)}
</div>
{row?.notes ? (
<div>
<p className="mb-2 text-overline uppercase text-ink-4">Notes</p>
<p className="text-body-sm text-ink-2">{row.notes}</p>
</div>
) : null}
</Stack>
)}
</Drawer>
);
}