updates on the ui changes and changed into doormile
This commit is contained in:
258
src/pages/doormile/reports/OrdersDetails.jsx
Normal file
258
src/pages/doormile/reports/OrdersDetails.jsx
Normal file
@@ -0,0 +1,258 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { FileSpreadsheet, ListOrdered } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Input, PageHeader, Select, SelectContent, SelectItem,
|
||||
SelectTrigger, SelectValue, Stack, StatusBadge,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useDeliveries, useRiderOptions, useTenants } from '@/lib/doormileHooks';
|
||||
import { formatDoormileTimestamp } from '@/lib/doormileTimestamp';
|
||||
import { currency, exportRows, km as formatKm, matchesQuery, orDash, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Orders details — the row-level export behind the summary.
|
||||
*
|
||||
* `GET /admin/bookings` accepts no date, status or search parameter, so the
|
||||
* range is applied when the rows are built and everything else is filtered
|
||||
* here. Pages are drained as they arrive rather than paged through by hand: a
|
||||
* report that silently stopped at the first hundred rows would be wrong in a
|
||||
* way nobody could see.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
export default function OrdersDetails() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [range, setRange] = useState({ from: today, to: today });
|
||||
const [tenantId, setTenantId] = useState(ALL);
|
||||
const [riderId, setRiderId] = useState(ALL);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const [page, setPage] = useState(1);
|
||||
const [collected, setCollected] = useState([]);
|
||||
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: riders = [] } = useRiderOptions();
|
||||
|
||||
const { data, isLoading, isFetching } = useDeliveries({
|
||||
page,
|
||||
pageSize: 200,
|
||||
from: range.from,
|
||||
to: range.to,
|
||||
});
|
||||
|
||||
/* A filter change invalidates everything already drained. */
|
||||
useEffect(() => {
|
||||
setCollected([]);
|
||||
setPage(1);
|
||||
}, [range.from, range.to]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.rows) return;
|
||||
setCollected((current) => {
|
||||
const seen = new Set(current.map((row) => row.orderheaderid));
|
||||
const merged = [...current];
|
||||
data.rows.forEach((row) => {
|
||||
if (!seen.has(row.orderheaderid)) merged.push(row);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
if (data.nextPage) setPage(data.nextPage);
|
||||
}, [data]);
|
||||
|
||||
const rows = useMemo(
|
||||
() =>
|
||||
collected
|
||||
.filter((row) => tenantId === ALL || String(row.tenantid) === tenantId)
|
||||
.filter((row) => riderId === ALL || String(row.userid) === riderId)
|
||||
.filter((row) =>
|
||||
matchesQuery(
|
||||
row,
|
||||
['orderid', 'tenantname', 'deliverycustomer', 'ridername', 'pickupaddress', 'deliveryaddress'],
|
||||
debouncedSearch
|
||||
)
|
||||
),
|
||||
[collected, tenantId, riderId, debouncedSearch]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'orderid',
|
||||
header: 'Order',
|
||||
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="text-caption text-ink-3">{formatDoormileTimestamp(row.orderdate, 'DD MMM, hh:mm A')}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ key: 'tenantname', header: 'Client', sortable: true, accessor: (row) => orDash(row.tenantname), hideBelow: 'md' },
|
||||
{
|
||||
key: 'pickupaddress',
|
||||
header: 'Pickup',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm text-ink-2" title={row.pickupaddress || ''}>
|
||||
{orDash(row.pickupaddress)}
|
||||
</p>
|
||||
),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'deliveryaddress',
|
||||
header: 'Drop',
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm text-ink-1">{orDash(row.deliverycustomer)}</p>
|
||||
<p className="truncate text-caption text-ink-3" title={row.deliveryaddress || ''}>
|
||||
{orDash(row.deliveryaddress)}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ridername',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
accessor: (row) => orDash(row.ridername),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.kms) || 0,
|
||||
accessor: (row) => formatKm(row.kms),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'deliverycharges',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.deliverycharges) || 0,
|
||||
accessor: (row) => currency(row.deliverycharges),
|
||||
},
|
||||
{
|
||||
key: 'orderstatus',
|
||||
header: 'Status',
|
||||
sortable: true,
|
||||
cell: (row) => <StatusBadge status={row.orderstatus} dot size="sm" />,
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
const exportColumns = [
|
||||
{ key: 'orderid', header: 'Order' },
|
||||
{ key: 'orderdate', header: 'Created' },
|
||||
{ key: 'tenantname', header: 'Client' },
|
||||
{ key: 'deliverycustomer', header: 'Customer' },
|
||||
{ key: 'pickupaddress', header: 'Pickup' },
|
||||
{ key: 'deliveryaddress', header: 'Drop' },
|
||||
{ key: 'ridername', header: 'Rider' },
|
||||
{ key: 'kms', header: 'Km' },
|
||||
{ key: 'deliverycharges', header: 'Amount' },
|
||||
{ key: 'orderstatus', header: 'Status' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={ListOrdered}
|
||||
title="Orders details"
|
||||
subtitle={`${collected.length} rows loaded · ${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">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by order, client, customer, rider or address"
|
||||
count={{ shown: rows.length, total: collected.length, noun: 'orders' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(rows, exportColumns, `orders-details-${range.from}`)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<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 && collected.length === 0}
|
||||
refreshing={isFetching && collected.length > 0}
|
||||
pageSize={50}
|
||||
defaultSort={{ key: 'orderid', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch) || tenantId !== ALL || riderId !== ALL}
|
||||
onClearFilters={() => {
|
||||
setSearch('');
|
||||
setTenantId(ALL);
|
||||
setRiderId(ALL);
|
||||
}}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={ListOrdered}
|
||||
title="Nothing in this range"
|
||||
description="No dispatched orders were recorded for the dates selected."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
313
src/pages/doormile/reports/OrdersSummary.jsx
Normal file
313
src/pages/doormile/reports/OrdersSummary.jsx
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { BarChart3, CheckCircle2, FileSpreadsheet, IndianRupee, Package, Timer } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import {
|
||||
useAppLocations, useReportLocationSummary, useReportSummary, useTenantLocations, useTenants,
|
||||
} from '@/lib/doormileHooks';
|
||||
import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Orders summary — the same counts the Orders page shows, grouped.
|
||||
*
|
||||
* Grouping follows the filter: with no client chosen the report comes back per
|
||||
* client, and choosing one switches the source to the per-site breakdown. Those
|
||||
* are two different endpoints returning two differently-shaped rows, which is
|
||||
* why the first column changes its name with the grouping.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
export default function OrdersSummary() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [range, setRange] = useState({ from: today, to: today });
|
||||
const [zone, setZone] = useState(ALL);
|
||||
const [tenantId, setTenantId] = useState(ALL);
|
||||
const [locationId, setLocationId] = useState(ALL);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
const { data: tenants = [] } = useTenants();
|
||||
const { data: tenantLocations = [] } = useTenantLocations(tenantId === ALL ? null : tenantId);
|
||||
|
||||
const groupedByLocation = tenantId !== ALL;
|
||||
|
||||
const appId = zone === ALL ? '' : zone;
|
||||
const tenantParam = tenantId === ALL ? '' : tenantId;
|
||||
const locationParam = locationId === ALL ? '' : locationId;
|
||||
|
||||
const summary = useReportSummary(appId, tenantParam, locationParam, range.from, range.to, {
|
||||
enabled: !groupedByLocation,
|
||||
});
|
||||
const locationSummary = useReportLocationSummary(appId, tenantParam, locationParam, range.from, range.to, {
|
||||
enabled: groupedByLocation,
|
||||
});
|
||||
|
||||
const query = groupedByLocation ? locationSummary : summary;
|
||||
|
||||
/* The per-client endpoint answers with a single object on some accounts and
|
||||
an array on others; both are normalised to rows here rather than at four
|
||||
separate call sites downstream. */
|
||||
const rows = useMemo(() => {
|
||||
const data = query.data;
|
||||
if (!data) return [];
|
||||
if (Array.isArray(data)) return data;
|
||||
if (Array.isArray(data.rows)) return data.rows;
|
||||
if (Array.isArray(data.by_tenant)) return data.by_tenant;
|
||||
if (Array.isArray(data.by_location)) return data.by_location;
|
||||
return [data];
|
||||
}, [query.data]);
|
||||
|
||||
const filteredRows = useMemo(
|
||||
() => rows.filter((row) => matchesQuery(row, ['tenantname', 'locationname', 'name'], debouncedSearch)),
|
||||
[rows, debouncedSearch]
|
||||
);
|
||||
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
filteredRows.reduce(
|
||||
(acc, row) => ({
|
||||
totalOrders: acc.totalOrders + (Number(row.totalorders) || 0),
|
||||
pending: acc.pending + (Number(row.Orderspending ?? row.orderspending) || 0),
|
||||
completed: acc.completed + (Number(row.orderscompleted) || 0),
|
||||
cancelled: acc.cancelled + (Number(row.orderscancelled) || 0),
|
||||
collection: acc.collection + (Number(row.collectionamt) || 0),
|
||||
kms: acc.kms + (Number(row.kms) || 0),
|
||||
/* Charges and delivery amount are the same money reported under two
|
||||
names depending on the account; taking the larger avoids counting
|
||||
a row as zero when only one of them is populated. */
|
||||
amount: acc.amount + Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0),
|
||||
}),
|
||||
{ totalOrders: 0, pending: 0, completed: 0, cancelled: 0, collection: 0, kms: 0, amount: 0 }
|
||||
),
|
||||
[filteredRows]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
header: groupedByLocation ? 'Site' : 'Client',
|
||||
sortable: true,
|
||||
accessor: (row) => row.locationname || row.tenantname || row.name || '—',
|
||||
cell: (row) => (
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">
|
||||
{row.locationname || row.tenantname || row.name || '—'}
|
||||
</p>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalorders',
|
||||
header: 'Orders',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.totalorders) || 0,
|
||||
accessor: (row) => number(row.totalorders, '0'),
|
||||
},
|
||||
{
|
||||
key: 'orderspending',
|
||||
header: 'Pending',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.Orderspending ?? row.orderspending) || 0,
|
||||
accessor: (row) => number(row.Orderspending ?? row.orderspending, '0'),
|
||||
},
|
||||
{
|
||||
key: 'orderscompleted',
|
||||
header: 'Completed',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.orderscompleted) || 0,
|
||||
accessor: (row) => number(row.orderscompleted, '0'),
|
||||
},
|
||||
{
|
||||
key: 'orderscancelled',
|
||||
header: 'Cancelled',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.orderscancelled) || 0,
|
||||
accessor: (row) => number(row.orderscancelled, '0'),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'deliveriescompleted',
|
||||
header: 'Deliveries done',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.deliveriescompleted) || 0,
|
||||
accessor: (row) => number(row.deliveriescompleted, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.kms) || 0,
|
||||
accessor: (row) => number(row.kms, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'collectionamt',
|
||||
header: 'Collection',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.collectionamt) || 0,
|
||||
accessor: (row) => currency(row.collectionamt),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'amount',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0),
|
||||
accessor: (row) => currency(Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0)),
|
||||
},
|
||||
],
|
||||
[groupedByLocation]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={BarChart3}
|
||||
title="Orders summary"
|
||||
subtitle={`${groupedByLocation ? 'By site' : 'By client'} · ${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"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Package} value={number(stats.totalOrders, '0')} label="Total orders" tone="brand" index={0} loading={query.isLoading} />
|
||||
<KpiCard
|
||||
icon={Timer}
|
||||
value={number(stats.pending, '0')}
|
||||
label={`Pending · ${percentOf(stats.pending, stats.totalOrders)}%`}
|
||||
tone="warning"
|
||||
index={1}
|
||||
loading={query.isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={number(stats.completed, '0')}
|
||||
label={`Completed · ${percentOf(stats.completed, stats.totalOrders)}%`}
|
||||
tone="success"
|
||||
index={2}
|
||||
loading={query.isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={IndianRupee}
|
||||
value={currency(stats.amount, '₹0')}
|
||||
label="Total amount"
|
||||
tone="navy"
|
||||
index={3}
|
||||
loading={query.isLoading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by client or site"
|
||||
count={{ shown: filteredRows.length, total: rows.length, noun: 'rows' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(filteredRows, columns, `orders-summary-${range.from}`)}
|
||||
disabled={filteredRows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={zone} onValueChange={setZone}>
|
||||
<SelectTrigger className="w-full sm:w-40">
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((entry) => entry.applocationid)
|
||||
.map((entry) => (
|
||||
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}>
|
||||
{entry.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={tenantId}
|
||||
onValueChange={(value) => {
|
||||
setTenantId(value);
|
||||
setLocationId(ALL);
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
|
||||
{groupedByLocation && (
|
||||
<Select value={locationId} onValueChange={setLocationId}>
|
||||
<SelectTrigger className="w-full sm:w-48">
|
||||
<SelectValue placeholder="All sites" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All sites</SelectItem>
|
||||
{(tenantLocations || []).map((entry) => (
|
||||
<SelectItem key={entry.locationid} value={String(entry.locationid)}>
|
||||
{entry.locationname || `Site #${entry.locationid}`}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={filteredRows}
|
||||
getRowId={(row, index) => row.tenantid ?? row.locationid ?? index}
|
||||
loading={query.isLoading}
|
||||
refreshing={query.isFetching && !query.isLoading}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'totalorders', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={BarChart3}
|
||||
title="Nothing in this range"
|
||||
description="No orders were recorded for the dates and filters selected."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
217
src/pages/doormile/reports/Profitability.jsx
Normal file
217
src/pages/doormile/reports/Profitability.jsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Fuel, IndianRupee, Percent, TrendingDown, TrendingUp, Wallet } from 'lucide-react';
|
||||
import {
|
||||
DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Stack, StatusBadge, Surface,
|
||||
} from '@/components/ds';
|
||||
import { useDeliveries } from '@/lib/doormileHooks';
|
||||
import {
|
||||
BASE_KM_LIMIT, BASE_REVENUE, EXTRA_RATE_KM, FIXED_COST_PER_SLOT, VARIABLE_RATE_KM,
|
||||
calcRiderMetrics, groupRowsByRider,
|
||||
} from '@/lib/profitability';
|
||||
import { currency, km as formatKm, number } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Profitability — what a day of deliveries actually earned, per rider.
|
||||
*
|
||||
* Revenue is ₹30 for the first 8 km and ₹6/km beyond it. Cost is ₹2.50/km of
|
||||
* fuel and wear, plus a slice of fixed salary for every slot the rider worked,
|
||||
* capped at three slots a day. Cancelled and skipped stops earn nothing and are
|
||||
* charged nothing — they still appear in the order count, because an operator
|
||||
* reading a rider's day needs to see them.
|
||||
*
|
||||
* Distance is the straight line between pickup and drop: a booking carries
|
||||
* coordinates but no road distance, so the real figure is somewhat higher than
|
||||
* what is shown here, which makes these margins the conservative read.
|
||||
*/
|
||||
|
||||
export default function Profitability() {
|
||||
const [date, setDate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||
const [page, setPage] = useState(1);
|
||||
const [collected, setCollected] = useState([]);
|
||||
|
||||
const { data, isLoading, isFetching } = useDeliveries({ page, pageSize: 200, from: date, to: date });
|
||||
|
||||
useEffect(() => {
|
||||
setCollected([]);
|
||||
setPage(1);
|
||||
}, [date]);
|
||||
|
||||
/* Drained rather than paged: a profit figure computed from the first page
|
||||
only would be confidently wrong, and nothing on screen would say so. */
|
||||
useEffect(() => {
|
||||
if (!data?.rows) return;
|
||||
setCollected((current) => {
|
||||
const seen = new Set(current.map((row) => row.orderheaderid));
|
||||
const merged = [...current];
|
||||
data.rows.forEach((row) => {
|
||||
if (!seen.has(row.orderheaderid)) merged.push(row);
|
||||
});
|
||||
return merged;
|
||||
});
|
||||
if (data.nextPage) setPage(data.nextPage);
|
||||
}, [data]);
|
||||
|
||||
const riders = useMemo(() => groupRowsByRider(collected), [collected]);
|
||||
|
||||
const enriched = useMemo(
|
||||
() =>
|
||||
riders
|
||||
.map((rider) => ({ ...rider, metrics: calcRiderMetrics(rider, date) }))
|
||||
/* A rider with no orders on this day is not a zero-margin rider — they
|
||||
simply did not work it, and averaging them in drags every figure. */
|
||||
.filter((rider) => rider.metrics.orders.length > 0),
|
||||
[riders, date]
|
||||
);
|
||||
|
||||
const totals = useMemo(
|
||||
() =>
|
||||
enriched.reduce(
|
||||
(acc, rider) => ({
|
||||
revenue: acc.revenue + rider.metrics.revenue,
|
||||
cost: acc.cost + rider.metrics.totalCost,
|
||||
net: acc.net + rider.metrics.net,
|
||||
kms: acc.kms + rider.metrics.kms,
|
||||
orders: acc.orders + rider.metrics.orders.length,
|
||||
}),
|
||||
{ revenue: 0, cost: 0, net: 0, kms: 0, orders: 0 }
|
||||
),
|
||||
[enriched]
|
||||
);
|
||||
|
||||
const margin = totals.revenue > 0 ? (totals.net / totals.revenue) * 100 : 0;
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'riderName',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{row.riderName}</p>
|
||||
<p className="text-caption text-ink-3">
|
||||
{row.metrics.orders.length} orders · {row.metrics.slotCount} slot
|
||||
{row.metrics.slotCount === 1 ? '' : 's'}
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'kms',
|
||||
header: 'Distance',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.kms,
|
||||
accessor: (row) => formatKm(row.metrics.kms),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'revenue',
|
||||
header: 'Revenue',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.revenue,
|
||||
accessor: (row) => currency(row.metrics.revenue),
|
||||
},
|
||||
{
|
||||
key: 'varCost',
|
||||
header: 'Fuel & wear',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.varCost,
|
||||
accessor: (row) => currency(row.metrics.varCost),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'fixedCost',
|
||||
header: 'Fixed',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.fixedCost,
|
||||
accessor: (row) => currency(row.metrics.fixedCost),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'net',
|
||||
header: 'Net',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.net,
|
||||
cell: (row) => (
|
||||
<span className={row.metrics.net >= 0 ? 'font-semibold text-success' : 'font-semibold text-destructive'}>
|
||||
{currency(row.metrics.net)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'margin',
|
||||
header: 'Margin',
|
||||
align: 'right',
|
||||
sortValue: (row) => row.metrics.margin,
|
||||
cell: (row) => (
|
||||
<StatusBadge
|
||||
status={row.metrics.margin >= 0 ? 'delivered' : 'cancelled'}
|
||||
label={`${row.metrics.margin.toFixed(0)}%`}
|
||||
size="sm"
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Wallet}
|
||||
title="Profitability"
|
||||
subtitle={dayjs(date).format('DD MMM YYYY')}
|
||||
actions={
|
||||
<Input
|
||||
type="date"
|
||||
value={date}
|
||||
onChange={(e) => e.target.value && setDate(e.target.value)}
|
||||
aria-label="Report date"
|
||||
className="w-44"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={IndianRupee} value={currency(totals.revenue, '₹0')} label="Revenue" tone="brand" index={0} loading={isLoading} />
|
||||
<KpiCard icon={Fuel} value={currency(totals.cost, '₹0')} label="Total cost" tone="warning" index={1} loading={isLoading} />
|
||||
<KpiCard
|
||||
icon={totals.net >= 0 ? TrendingUp : TrendingDown}
|
||||
value={currency(totals.net, '₹0')}
|
||||
label={`Net across ${number(totals.orders, '0')} orders`}
|
||||
tone={totals.net >= 0 ? 'success' : 'navy'}
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard icon={Percent} value={`${margin.toFixed(0)}%`} label="Margin" tone="accent" index={3} loading={isLoading} />
|
||||
</Grid>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={enriched}
|
||||
getRowId={(row) => row.id}
|
||||
loading={isLoading && collected.length === 0}
|
||||
refreshing={isFetching && collected.length > 0}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'net', direction: 'desc' }}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Wallet}
|
||||
title={`No deliveries on ${dayjs(date).format('DD MMM YYYY')}`}
|
||||
description="Pick a day with dispatched orders to see how it performed."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<Surface variant="subtle" padding="sm" radius="lg">
|
||||
<p className="text-caption leading-relaxed text-ink-3">
|
||||
Revenue: ₹{BASE_REVENUE} for the first {BASE_KM_LIMIT} km, then ₹{EXTRA_RATE_KM}/km. Cost: ₹
|
||||
{VARIABLE_RATE_KM}/km fuel and wear, plus ₹{FIXED_COST_PER_SLOT.toFixed(2)} per slot worked (capped at three a
|
||||
day). Distance is the straight line between pickup and drop — the road distance is higher, so these margins
|
||||
are the conservative read.
|
||||
</p>
|
||||
</Surface>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
220
src/pages/doormile/reports/RidersSummary.jsx
Normal file
220
src/pages/doormile/reports/RidersSummary.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Bike, CheckCircle2, FileSpreadsheet, IndianRupee, Package } from 'lucide-react';
|
||||
import {
|
||||
Button, DataTable, EmptyState, Grid, Input, KpiCard, PageHeader, Select, SelectContent,
|
||||
SelectItem, SelectTrigger, SelectValue, Stack,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
import { useAppLocations, useRidersSummary } from '@/lib/doormileHooks';
|
||||
import { currency, exportRows, matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat';
|
||||
|
||||
/**
|
||||
* Riders summary — per-rider KPI counts for a date range.
|
||||
*
|
||||
* Backed by `/admin/milers/summary`, whose per-row field names are not
|
||||
* documented. Each column reads defensively and falls back to a dash rather
|
||||
* than rendering `undefined`, so an unexpected shape degrades to blanks instead
|
||||
* of breaking the page.
|
||||
*/
|
||||
|
||||
const ALL = 'all';
|
||||
|
||||
export default function RidersSummary() {
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const [range, setRange] = useState({ from: today, to: today });
|
||||
const [zone, setZone] = useState(ALL);
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebouncedValue(search);
|
||||
|
||||
const { data: locations = [] } = useAppLocations();
|
||||
const { data: summary = [], isLoading, isFetching } = useRidersSummary(
|
||||
zone === ALL ? '' : zone,
|
||||
range.from,
|
||||
range.to
|
||||
);
|
||||
|
||||
const rows = useMemo(
|
||||
() => (summary || []).filter((row) => matchesQuery(row, ['displayname', 'phone', 'hubname'], debouncedSearch)),
|
||||
[summary, debouncedSearch]
|
||||
);
|
||||
|
||||
const stats = useMemo(
|
||||
() =>
|
||||
rows.reduce(
|
||||
(acc, row) => ({
|
||||
riders: acc.riders + 1,
|
||||
orders: acc.orders + (Number(row.assigned) || 0),
|
||||
delivered: acc.delivered + (Number(row.delivered) || 0),
|
||||
amount: acc.amount + (Number(row.ridercharges) || 0),
|
||||
}),
|
||||
{ riders: 0, orders: 0, delivered: 0, amount: 0 }
|
||||
),
|
||||
[rows]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'displayname',
|
||||
header: 'Rider',
|
||||
sortable: true,
|
||||
cell: (row) => (
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{row.displayname || `Rider #${row.userid}`}</p>
|
||||
<p className="text-caption text-ink-3">{row.hubname || row.phone || '—'}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'assigned',
|
||||
header: 'Assigned',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.assigned) || 0,
|
||||
accessor: (row) => number(row.assigned, '0'),
|
||||
},
|
||||
{
|
||||
key: 'accepted',
|
||||
header: 'Accepted',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.accepted) || 0,
|
||||
accessor: (row) => number(row.accepted, '0'),
|
||||
hideBelow: 'md',
|
||||
},
|
||||
{
|
||||
key: 'delivered',
|
||||
header: 'Delivered',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.delivered) || 0,
|
||||
accessor: (row) => number(row.delivered, '0'),
|
||||
},
|
||||
{
|
||||
key: 'cancelled',
|
||||
header: 'Cancelled',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.cancelled) || 0,
|
||||
accessor: (row) => number(row.cancelled, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'riderkms',
|
||||
header: 'Km',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.riderkms) || 0,
|
||||
accessor: (row) => number(row.riderkms, '0'),
|
||||
hideBelow: 'lg',
|
||||
},
|
||||
{
|
||||
key: 'ridercharges',
|
||||
header: 'Amount',
|
||||
align: 'right',
|
||||
sortValue: (row) => Number(row.ridercharges) || 0,
|
||||
accessor: (row) => currency(row.ridercharges),
|
||||
},
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<Stack space="lg">
|
||||
<PageHeader
|
||||
icon={Bike}
|
||||
title="Riders summary"
|
||||
subtitle={`${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"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid cols={4}>
|
||||
<KpiCard icon={Bike} value={number(stats.riders, '0')} label="Riders active" tone="brand" index={0} loading={isLoading} />
|
||||
<KpiCard icon={Package} value={number(stats.orders, '0')} label="Orders assigned" tone="navy" index={1} loading={isLoading} />
|
||||
<KpiCard
|
||||
icon={CheckCircle2}
|
||||
value={number(stats.delivered, '0')}
|
||||
label={`Delivered · ${percentOf(stats.delivered, stats.orders)}%`}
|
||||
tone="success"
|
||||
index={2}
|
||||
loading={isLoading}
|
||||
/>
|
||||
<KpiCard
|
||||
icon={IndianRupee}
|
||||
value={currency(stats.amount, '₹0')}
|
||||
label="Rider charges"
|
||||
tone="accent"
|
||||
index={3}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Stack space="sm">
|
||||
<ListToolbar
|
||||
search={search}
|
||||
onSearchChange={setSearch}
|
||||
placeholder="Search by rider, phone or hub"
|
||||
count={{ shown: rows.length, total: (summary || []).length, noun: 'riders' }}
|
||||
actions={
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => exportRows(rows, columns, `riders-summary-${range.from}`)}
|
||||
disabled={rows.length === 0}
|
||||
>
|
||||
<FileSpreadsheet className="mr-1.5 h-4 w-4" /> Export
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Select value={zone} onValueChange={setZone}>
|
||||
<SelectTrigger className="w-full sm:w-44">
|
||||
<SelectValue placeholder="All zones" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>All zones</SelectItem>
|
||||
{(locations || [])
|
||||
.filter((entry) => entry.applocationid)
|
||||
.map((entry) => (
|
||||
<SelectItem key={entry.applocationid} value={String(entry.applocationid)}>
|
||||
{entry.locationname}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</ListToolbar>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
getRowId={(row, index) => row.milerprofileid ?? row.userid ?? index}
|
||||
loading={isLoading}
|
||||
refreshing={isFetching && !isLoading}
|
||||
pageSize={25}
|
||||
defaultSort={{ key: 'delivered', direction: 'desc' }}
|
||||
isFiltered={Boolean(debouncedSearch)}
|
||||
onClearFilters={() => setSearch('')}
|
||||
emptyState={
|
||||
<EmptyState
|
||||
icon={Bike}
|
||||
title="Nothing in this range"
|
||||
description="No rider activity was recorded for the dates selected."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user