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

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

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

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