updates on the ui changes and changed into doormile
This commit is contained in:
693
src/pages/doormile/deliveries/Deliveries.jsx
Normal file
693
src/pages/doormile/deliveries/Deliveries.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user