updatess on the ui changs
This commit is contained in:
@@ -32,7 +32,8 @@ import {
|
|||||||
TruckOutlined,
|
TruckOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
RadarChartOutlined,
|
RadarChartOutlined,
|
||||||
RiseOutlined
|
RiseOutlined,
|
||||||
|
RobotOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// icons
|
// icons
|
||||||
@@ -66,7 +67,8 @@ const icons = {
|
|||||||
TruckOutlined,
|
TruckOutlined,
|
||||||
WarningOutlined,
|
WarningOutlined,
|
||||||
RadarChartOutlined,
|
RadarChartOutlined,
|
||||||
RiseOutlined
|
RiseOutlined,
|
||||||
|
RobotOutlined
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
|
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
|
||||||
@@ -126,6 +128,13 @@ const nearle = {
|
|||||||
url: '/doormile/customers',
|
url: '/doormile/customers',
|
||||||
icon: icons.ContactsOutlined
|
icon: icons.ContactsOutlined
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'assistant',
|
||||||
|
title: <FormattedMessage id="assistant" />,
|
||||||
|
type: 'item',
|
||||||
|
url: '/doormile/assistant',
|
||||||
|
icon: icons.RobotOutlined
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'fleetops',
|
id: 'fleetops',
|
||||||
title: <FormattedMessage id="fleetops" />,
|
title: <FormattedMessage id="fleetops" />,
|
||||||
|
|||||||
@@ -71,7 +71,11 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
|||||||
delivered: 'delivered',
|
delivered: 'delivered',
|
||||||
cancelled: 'cancelled'
|
cancelled: 'cancelled'
|
||||||
};
|
};
|
||||||
const mapBookingStatusToDeliveryStatus = (status) => {
|
// Exported (not just module-local) so anything else that needs to classify a
|
||||||
|
// raw booking status — the operator bot's status-breakdown intent included —
|
||||||
|
// reuses this instead of growing a second copy of BOOKING_STATUS_TO_DELIVERY_STATUS
|
||||||
|
// that can drift from it.
|
||||||
|
export const mapBookingStatusToDeliveryStatus = (status) => {
|
||||||
const key = String(status || '').toLowerCase();
|
const key = String(status || '').toLowerCase();
|
||||||
return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key;
|
return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key;
|
||||||
};
|
};
|
||||||
@@ -636,9 +640,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
|
|||||||
// meant an order re-stamped during the evening silently jumped out of the
|
// meant an order re-stamped during the evening silently jumped out of the
|
||||||
// batch it was actually assigned to and into whichever window contained
|
// batch it was actually assigned to and into whichever window contained
|
||||||
// the current clock time — the same orders appearing under Afternoon and
|
// the current clock time — the same orders appearing under Afternoon and
|
||||||
// then Evening on the same day. Both now bucket on
|
// then Evening on the same day. Both now bucket on `orderdate` (this
|
||||||
// `expecteddeliverytime`, which is stable. It remains fine to DISPLAY
|
// row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's
|
||||||
// this as a "last updated" stamp, which is all the reports use it for.
|
// table for why `expecteddeliverytime` was also tried and rejected (it's
|
||||||
|
// the promised delivery slot, not the wave the order was placed in). It
|
||||||
|
// remains fine to DISPLAY assigntime as a "last updated" stamp, which is
|
||||||
|
// all the reports use it for.
|
||||||
assigntime: b.updatedat,
|
assigntime: b.updatedat,
|
||||||
orderstatus: mapBookingStatusToDeliveryStatus(b.status),
|
orderstatus: mapBookingStatusToDeliveryStatus(b.status),
|
||||||
droplat: b.deliverylatitude,
|
droplat: b.deliverylatitude,
|
||||||
|
|||||||
@@ -1,19 +1,37 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md';
|
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
@@ -47,12 +65,38 @@ const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1)
|
|||||||
|
|
||||||
const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
|
const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const APP_USERS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const APP_USERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const AppUsers = () => {
|
const AppUsers = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [searchword, setSearchword] = useState('');
|
const [searchword, setSearchword] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers });
|
const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers });
|
||||||
|
|
||||||
@@ -132,6 +176,129 @@ const AppUsers = () => {
|
|||||||
|
|
||||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// A search change re-scopes the list, so page 1 is the only page that
|
||||||
|
// still means anything.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as
|
||||||
|
// orders.js's/riders.js's/Tenants.js's `columns`.
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 40,
|
||||||
|
minSize: 32,
|
||||||
|
maxSize: 48,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'first_name',
|
||||||
|
size: 200,
|
||||||
|
minSize: 150,
|
||||||
|
meta: { label: 'Name' },
|
||||||
|
header: () => <SectionLabel>Name</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'email',
|
||||||
|
accessorKey: 'email',
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Email' },
|
||||||
|
header: () => <SectionLabel>Email</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phone',
|
||||||
|
accessorKey: 'phone',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Phone' },
|
||||||
|
header: () => <SectionLabel>Phone</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'role',
|
||||||
|
accessorFn: (row) => roleLabel(row.role),
|
||||||
|
size: 130,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Role' },
|
||||||
|
header: () => <SectionLabel>Role</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Edit user"
|
||||||
|
tooltip="Edit user"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Delete user"
|
||||||
|
tooltip="Delete user"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={16} />}
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[pagination.pageIndex, pagination.pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: APP_USERS_TABLE_FEATURES,
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.id ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||||
@@ -170,24 +337,107 @@ const AppUsers = () => {
|
|||||||
</FilterToolbar>
|
</FilterToolbar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableScroll minWidth={880}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="App Users"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Email</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Phone</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Role</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
<TableHeaderCell
|
||||||
</TableRow>
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={7} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No staff users to show"
|
title="No staff users to show"
|
||||||
description="Create a staff login to give someone console access."
|
description="Create a staff login to give someone console access."
|
||||||
@@ -196,42 +446,49 @@ const AppUsers = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.id || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{index + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>
|
<TableCell
|
||||||
<Text weight="bold">{row.first_name}</Text>
|
key={cell.id}
|
||||||
</TableCell>
|
style={{
|
||||||
<TableCell>{row.email}</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>{row.phone || '—'}</TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell>{roleLabel(row.role)}</TableCell>
|
}}
|
||||||
<TableCell>{row.status || '—'}</TableCell>
|
>
|
||||||
<TableCell>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<HStack gap={1} justify="end">
|
</TableCell>
|
||||||
<IconButton
|
))}
|
||||||
label="Edit user"
|
|
||||||
tooltip="Edit user"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdEdit size={16} />}
|
|
||||||
onClick={() => openEdit(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Delete user"
|
|
||||||
tooltip="Delete user"
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
icon={<MdDeleteOutline size={16} />}
|
|
||||||
onClick={() => handleDelete(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
rows.length
|
||||||
|
)} of ${rows.length} user${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={APP_USERS_PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="App users pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
150
src/pages/nearle/assistant/assistant.js
Normal file
150
src/pages/nearle/assistant/assistant.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import { MdSmartToy } from 'react-icons/md';
|
||||||
|
|
||||||
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
|
import {
|
||||||
|
ChatLayout,
|
||||||
|
ChatMessageList,
|
||||||
|
ChatMessage,
|
||||||
|
ChatMessageBubble,
|
||||||
|
ChatMessageMetadata,
|
||||||
|
ChatComposer,
|
||||||
|
ChatToolCalls
|
||||||
|
} from '@astryxdesign/core/Chat';
|
||||||
|
|
||||||
|
import PageHeader from 'components/nearle_components/PageHeader';
|
||||||
|
import { PageShell } from 'components/nearle_components/PageLayout';
|
||||||
|
import { OpenToast } from 'components/third-party/OpenToast';
|
||||||
|
import { DT } from 'themes/dt/tokens';
|
||||||
|
import { answerQuestion, SUPPORTED_QUESTIONS } from './intents';
|
||||||
|
|
||||||
|
// ==============================|| Doormile Bot ||============================== //
|
||||||
|
//
|
||||||
|
// Deterministic Q&A over live order/rider data — see intents.js for the
|
||||||
|
// intent catalog and the plan this was built from (no LLM: every answer
|
||||||
|
// comes from a real API call, never a generated guess). This file owns only
|
||||||
|
// the chat UI + message state; all data logic lives in intents.js.
|
||||||
|
|
||||||
|
let nextId = 1;
|
||||||
|
const makeId = () => nextId++;
|
||||||
|
|
||||||
|
const now = () => dayjs().format('hh:mm A');
|
||||||
|
|
||||||
|
export default function Assistant() {
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [value, setValue] = useState('');
|
||||||
|
const [isSending, setIsSending] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async (text) => {
|
||||||
|
const question = text.trim();
|
||||||
|
if (!question || isSending) return;
|
||||||
|
|
||||||
|
setValue('');
|
||||||
|
setMessages((prev) => [...prev, { id: makeId(), sender: 'user', text: question, timestamp: now() }]);
|
||||||
|
setIsSending(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await answerQuestion(question);
|
||||||
|
if (result) {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: makeId(),
|
||||||
|
sender: 'assistant',
|
||||||
|
text: result.headline,
|
||||||
|
detail: result.detail,
|
||||||
|
sourceCalls: result.sourceCalls,
|
||||||
|
timestamp: now()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: makeId(),
|
||||||
|
sender: 'assistant',
|
||||||
|
text: "I can't answer that yet.",
|
||||||
|
detail: `Right now I can answer:\n${SUPPORTED_QUESTIONS.map((q) => `• ${q}`).join('\n')}`,
|
||||||
|
timestamp: now()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
OpenToast(err.response?.data?.message || err.message || 'Failed to fetch an answer', 'error', 2500);
|
||||||
|
setMessages((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: makeId(),
|
||||||
|
sender: 'assistant',
|
||||||
|
text: 'Something went wrong fetching that — try again in a moment.',
|
||||||
|
timestamp: now()
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
setIsSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell fill>
|
||||||
|
<PageHeader title="Assistant" subtitle="Live · Ask Doormile" live />
|
||||||
|
|
||||||
|
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
|
||||||
|
<ChatLayout
|
||||||
|
density="balanced"
|
||||||
|
composer={
|
||||||
|
<ChatComposer
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
isDisabled={isSending}
|
||||||
|
placeholder="Ask about today's orders, batches, riders…"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
emptyState={
|
||||||
|
<Center style={{ height: '100%' }}>
|
||||||
|
<VStack gap={2} padding={4} vAlign="center" style={{ textAlign: 'center', maxWidth: 420 }}>
|
||||||
|
<MdSmartToy size={32} color={DT.textSecondary} />
|
||||||
|
<Text weight="bold">Ask Doormile</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
I answer from live data only — no guessing. Try:
|
||||||
|
</Text>
|
||||||
|
<VStack gap={0.5} padding={0}>
|
||||||
|
{SUPPORTED_QUESTIONS.map((q) => (
|
||||||
|
<Text key={q} type="supporting" color="secondary">
|
||||||
|
• {q}
|
||||||
|
</Text>
|
||||||
|
))}
|
||||||
|
</VStack>
|
||||||
|
</VStack>
|
||||||
|
</Center>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ChatMessageList isStreaming={isSending}>
|
||||||
|
{messages.map((m) => (
|
||||||
|
<ChatMessage key={m.id} sender={m.sender}>
|
||||||
|
<ChatMessageBubble metadata={<ChatMessageMetadata timestamp={m.timestamp} />}>
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
<Text>{m.text}</Text>
|
||||||
|
{m.detail && (
|
||||||
|
<Text type="supporting" color="secondary" style={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{m.detail}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
</ChatMessageBubble>
|
||||||
|
{/* Audit trail — shows exactly what was queried and how many
|
||||||
|
rows came back, so the answer is verifiable, not a claim
|
||||||
|
the operator has to take on faith. */}
|
||||||
|
{m.sourceCalls && <ChatToolCalls calls={m.sourceCalls} />}
|
||||||
|
</ChatMessage>
|
||||||
|
))}
|
||||||
|
</ChatMessageList>
|
||||||
|
</ChatLayout>
|
||||||
|
</VStack>
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
215
src/pages/nearle/assistant/intents.js
Normal file
215
src/pages/nearle/assistant/intents.js
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
import { getBookings } from 'pages/api/doormileApi';
|
||||||
|
import { getalltenants, getallridersummary, mapBookingStatusToDeliveryStatus } from 'pages/api/api';
|
||||||
|
import { parseDoormileTimestamp } from 'utils/doormileTimestamp';
|
||||||
|
import { getRowBatchId, getBatchLabel } from 'utils/batchBucket';
|
||||||
|
|
||||||
|
// ==============================|| Doormile Bot — intent catalog ||============================== //
|
||||||
|
//
|
||||||
|
// Deterministic, not LLM-based (see the plan this was built from): every
|
||||||
|
// answer comes from a real API call against the same functions the rest of
|
||||||
|
// the console uses, never from a generated guess. `match` extracts params
|
||||||
|
// from raw text or returns null (this intent doesn't apply); `run` calls
|
||||||
|
// real data and returns { headline, detail, sourceCalls } — `sourceCalls`
|
||||||
|
// feeds <ChatToolCalls> so an operator can see exactly what was queried.
|
||||||
|
//
|
||||||
|
// `run` may also return null (matched the pattern but couldn't resolve
|
||||||
|
// something, e.g. an unrecognised tenant name) — the caller then tries the
|
||||||
|
// next intent in the list rather than answering with a guess.
|
||||||
|
//
|
||||||
|
// GET /admin/bookings has no server-side date/status/tenant filter — every
|
||||||
|
// intent bulk-fetches and filters client-side, the same pattern
|
||||||
|
// fetchDeliveries (api.js) already uses.
|
||||||
|
const BULK_PAGESIZE = 1000;
|
||||||
|
|
||||||
|
const TODAY = () => dayjs().format('YYYY-MM-DD');
|
||||||
|
|
||||||
|
const dayFromWords = (text) => {
|
||||||
|
if (/\byesterday\b/i.test(text)) return dayjs().subtract(1, 'day').format('YYYY-MM-DD');
|
||||||
|
return TODAY();
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeDay = (day) => (day === TODAY() ? 'today' : dayjs(day).format('DD MMM YYYY'));
|
||||||
|
|
||||||
|
const batchFromWords = (text) => {
|
||||||
|
if (/\bmorning\b/i.test(text)) return 'morning';
|
||||||
|
if (/\bafternoon\b/i.test(text)) return 'afternoon';
|
||||||
|
if (/\bevening\b/i.test(text)) return 'evening';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFromWords = (text) => {
|
||||||
|
if (/\bpending\b/i.test(text)) return 'pending';
|
||||||
|
if (/\bcancel(led)?\b/i.test(text)) return 'cancelled';
|
||||||
|
if (/\baccept(ed)?\b/i.test(text)) return 'accepted';
|
||||||
|
if (/\bactive\b|\bin[- ]?transit\b/i.test(text)) return 'active';
|
||||||
|
// Checked after the more specific ones above — "delivered" would also
|
||||||
|
// false-match "undelivered" without the negative lookahead-ish guard.
|
||||||
|
if (/\bdeliver(ed)?\b/i.test(text) && !/\bundeliver/i.test(text)) return 'delivered';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bulk-fetch bookings and keep only the ones created on `day` — same
|
||||||
|
// client-side date filter shape as fetchDeliveries' `inRange` in api.js.
|
||||||
|
const fetchBookingsForDay = async (day) => {
|
||||||
|
const bookings = (await getBookings(1, BULK_PAGESIZE)) || [];
|
||||||
|
return bookings.filter((b) => {
|
||||||
|
const d = parseDoormileTimestamp(b.createdat);
|
||||||
|
return d.isValid() && d.format('YYYY-MM-DD') === day;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const summarizeStatuses = (rows) => {
|
||||||
|
const counts = {};
|
||||||
|
rows.forEach((b) => {
|
||||||
|
const s = mapBookingStatusToDeliveryStatus(b.status);
|
||||||
|
counts[s] = (counts[s] || 0) + 1;
|
||||||
|
});
|
||||||
|
return Object.entries(counts)
|
||||||
|
.map(([k, v]) => `${v} ${k}`)
|
||||||
|
.join(', ');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveTenant = async (text) => {
|
||||||
|
const tenants = (await getalltenants()) || [];
|
||||||
|
const lower = text.toLowerCase();
|
||||||
|
return tenants.find((t) => t.tenantname && lower.includes(String(t.tenantname).toLowerCase()));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bookingLabel = (b) => b.bookingno || `#${b.bookingid}`;
|
||||||
|
|
||||||
|
const INTENTS = [
|
||||||
|
{
|
||||||
|
id: 'batchCount',
|
||||||
|
label: 'Orders in a batch — e.g. "morning batch orders today"',
|
||||||
|
match: (text) => {
|
||||||
|
const batch = batchFromWords(text);
|
||||||
|
if (!batch) return null;
|
||||||
|
return { batch, day: dayFromWords(text) };
|
||||||
|
},
|
||||||
|
run: async ({ batch, day }) => {
|
||||||
|
const rows = await fetchBookingsForDay(day);
|
||||||
|
const matched = rows.filter((b) => getRowBatchId({ orderdate: b.createdat }) === batch);
|
||||||
|
return {
|
||||||
|
headline: `${matched.length} order${matched.length === 1 ? '' : 's'} in the ${getBatchLabel(batch)} ${describeDay(day)}.`,
|
||||||
|
detail: matched.length
|
||||||
|
? `Order IDs: ${matched.slice(0, 10).map(bookingLabel).join(', ')}${
|
||||||
|
matched.length > 10 ? ` …and ${matched.length - 10} more` : ''
|
||||||
|
}`
|
||||||
|
: 'No orders fall in this batch for that day.',
|
||||||
|
sourceCalls: [
|
||||||
|
{
|
||||||
|
name: 'getBookings',
|
||||||
|
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
|
||||||
|
status: 'complete',
|
||||||
|
stats: `${rows.length} created ${describeDay(day)} → ${matched.length} in ${batch}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'statusBreakdown',
|
||||||
|
label: 'Orders by status — e.g. "how many delivered orders today"',
|
||||||
|
match: (text) => {
|
||||||
|
const status = statusFromWords(text);
|
||||||
|
if (!status) return null;
|
||||||
|
return { status, day: dayFromWords(text) };
|
||||||
|
},
|
||||||
|
run: async ({ status, day }) => {
|
||||||
|
const rows = await fetchBookingsForDay(day);
|
||||||
|
const matched = rows.filter((b) => mapBookingStatusToDeliveryStatus(b.status) === status);
|
||||||
|
return {
|
||||||
|
headline: `${matched.length} ${status} order${matched.length === 1 ? '' : 's'} ${describeDay(day)}.`,
|
||||||
|
detail: `Out of ${rows.length} orders created ${describeDay(day)}.`,
|
||||||
|
sourceCalls: [
|
||||||
|
{
|
||||||
|
name: 'getBookings',
|
||||||
|
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
|
||||||
|
status: 'complete',
|
||||||
|
stats: `${rows.length} total → ${matched.length} ${status}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tenantCount',
|
||||||
|
label: 'Orders for a tenant — e.g. "orders for <tenant name> today"',
|
||||||
|
match: (text) => {
|
||||||
|
if (!/\bfor\b/i.test(text) && !/\btenant\b/i.test(text)) return null;
|
||||||
|
return { text, day: dayFromWords(text) };
|
||||||
|
},
|
||||||
|
run: async ({ text, day }) => {
|
||||||
|
const tenant = await resolveTenant(text);
|
||||||
|
// No tenant name recognised in the question — don't guess which one
|
||||||
|
// was meant, fall through to the next intent instead.
|
||||||
|
if (!tenant) return null;
|
||||||
|
const rows = await fetchBookingsForDay(day);
|
||||||
|
const matched = rows.filter((b) => Number(b.tenantid) === Number(tenant.tenantid));
|
||||||
|
return {
|
||||||
|
headline: `${matched.length} order${matched.length === 1 ? '' : 's'} for ${tenant.tenantname} ${describeDay(day)}.`,
|
||||||
|
detail: `Out of ${rows.length} orders created ${describeDay(day)} across all tenants.`,
|
||||||
|
sourceCalls: [
|
||||||
|
{ name: 'getalltenants', target: '/admin/tenants', status: 'complete' },
|
||||||
|
{
|
||||||
|
name: 'getBookings',
|
||||||
|
target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`,
|
||||||
|
status: 'complete',
|
||||||
|
stats: `${rows.length} total → ${matched.length} for ${tenant.tenantname}`
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'riderCounts',
|
||||||
|
label: 'Rider availability — e.g. "how many riders are active"',
|
||||||
|
match: (text) => (/\brider(s)?\b/i.test(text) ? {} : null),
|
||||||
|
run: async () => {
|
||||||
|
const summary = await getallridersummary();
|
||||||
|
return {
|
||||||
|
headline: `${summary?.active ?? 0} active riders (${summary?.available ?? 0} available, ${
|
||||||
|
summary?.onDelivery ?? 0
|
||||||
|
} on a delivery) out of ${summary?.total ?? 0} total.`,
|
||||||
|
detail: `${summary?.inactive ?? 0} inactive/offline.`,
|
||||||
|
sourceCalls: [{ name: 'getallridersummary', target: '/admin/milers', status: 'complete', stats: `${summary?.total ?? 0} riders` }]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Deliberately last — the broadest match ("order"/"orders" alone), so
|
||||||
|
// every more specific intent above gets first refusal.
|
||||||
|
id: 'totalOrders',
|
||||||
|
label: 'Today\'s total order count — e.g. "how many orders today"',
|
||||||
|
match: (text) => (/\border(s)?\b/i.test(text) ? { day: dayFromWords(text) } : null),
|
||||||
|
run: async ({ day }) => {
|
||||||
|
const rows = await fetchBookingsForDay(day);
|
||||||
|
return {
|
||||||
|
headline: `${rows.length} order${rows.length === 1 ? '' : 's'} created ${describeDay(day)}.`,
|
||||||
|
detail: rows.length ? `Statuses: ${summarizeStatuses(rows)}` : undefined,
|
||||||
|
sourceCalls: [
|
||||||
|
{ name: 'getBookings', target: `/admin/bookings (page 1, ${BULK_PAGESIZE})`, status: 'complete', stats: `${rows.length} matched` }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SUPPORTED_QUESTIONS = INTENTS.map((i) => i.label);
|
||||||
|
|
||||||
|
// Tries each intent in order; the first one whose `match` recognises the
|
||||||
|
// text AND whose `run` resolves to a real answer wins. Returns null if
|
||||||
|
// nothing matched (or every match failed to resolve) — the caller shows the
|
||||||
|
// "I can't answer that yet" fallback rather than a guess.
|
||||||
|
export async function answerQuestion(text) {
|
||||||
|
for (const intent of INTENTS) {
|
||||||
|
const params = intent.match(text);
|
||||||
|
if (!params) continue;
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const result = await intent.run(params);
|
||||||
|
if (result) return result;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
MdLocalOffer,
|
MdLocalOffer,
|
||||||
MdGroups,
|
MdGroups,
|
||||||
@@ -13,25 +13,46 @@ import {
|
|||||||
MdAdd,
|
MdAdd,
|
||||||
MdEdit,
|
MdEdit,
|
||||||
MdDeleteOutline,
|
MdDeleteOutline,
|
||||||
MdCalculate
|
MdCalculate,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
@@ -71,6 +92,29 @@ const MetricPill = ({ label }) => (
|
|||||||
|
|
||||||
const CategoryChip = ({ icon, label }) => <Badge variant="neutral" icon={icon} label={label} />;
|
const CategoryChip = ({ icon, label }) => <Badge variant="neutral" icon={icon} label={label} />;
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const PRICING_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
pricingid: null,
|
pricingid: null,
|
||||||
tenant: '',
|
tenant: '',
|
||||||
@@ -100,6 +144,11 @@ const ClientsPricing = () => {
|
|||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||||
const [quoteForm, setQuoteForm] = useState({ tenant: '', location: '', vehicletype: 'Bike', weight: '', distance: '' });
|
const [quoteForm, setQuoteForm] = useState({ tenant: '', location: '', vehicletype: 'Bike', weight: '', distance: '' });
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
|
// slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: pricing = [], isLoading } = useQuery({
|
const { data: pricing = [], isLoading } = useQuery({
|
||||||
queryKey: ['getallpricing', appId],
|
queryKey: ['getallpricing', appId],
|
||||||
@@ -151,6 +200,12 @@ const ClientsPricing = () => {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [zonePricing, debouncedSearch, tenantMap, locationMap]);
|
}, [zonePricing, debouncedSearch, tenantMap, locationMap]);
|
||||||
|
|
||||||
|
// A zone/search change re-scopes the list, so page 1 is the only page that
|
||||||
|
// still means anything — same reset orders.js/riders.js do.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [appId, debouncedSearch]);
|
||||||
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
const total = zonePricing.length;
|
const total = zonePricing.length;
|
||||||
const tenantsPriced = new Set(zonePricing.map((r) => r.tenantid).filter(Boolean)).size;
|
const tenantsPriced = new Set(zonePricing.map((r) => r.tenantid).filter(Boolean)).size;
|
||||||
@@ -281,6 +336,166 @@ const ClientsPricing = () => {
|
|||||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
const setQuote = (key) => (value) => setQuoteForm((f) => ({ ...f, [key]: value }));
|
const setQuote = (key) => (value) => setQuoteForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as
|
||||||
|
// orders.js's/riders.js's/Tenants.js's `columns`. Tenant/Zone keep the
|
||||||
|
// widths from this file's earlier column-squeeze fix (Tenant needed real
|
||||||
|
// width so Zone's text didn't get crushed under `table-layout: auto`).
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tenant',
|
||||||
|
accessorFn: (row) => tenantName(row),
|
||||||
|
size: 260,
|
||||||
|
minSize: 240,
|
||||||
|
meta: { label: 'Tenant' },
|
||||||
|
header: () => <SectionLabel>Tenant</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<AccentAvatar color={BRAND} size={36}>
|
||||||
|
<MdGroups size={18} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
ID #{row.original.pricingid}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zone',
|
||||||
|
accessorFn: (row) => zoneName(row),
|
||||||
|
size: 180,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Zone' },
|
||||||
|
header: () => <SectionLabel>Zone</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CategoryChip icon={<MdPlace size={12} />} label={getValue()} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicle',
|
||||||
|
accessorKey: 'vehicletype',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Vehicle' },
|
||||||
|
header: () => <SectionLabel>Vehicle</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CategoryChip icon={<MdSpeed size={12} />} label={getValue() || '—'} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'baseprice',
|
||||||
|
accessorKey: 'baseprice',
|
||||||
|
size: 130,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Base Price' },
|
||||||
|
header: () => <SectionLabel>Base Price</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priceperkm',
|
||||||
|
accessorKey: 'priceperkm',
|
||||||
|
size: 130,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Price / KM' },
|
||||||
|
header: () => <SectionLabel>Price / KM</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'priceperkg',
|
||||||
|
accessorKey: 'priceperkg',
|
||||||
|
size: 130,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Price / KG' },
|
||||||
|
header: () => <SectionLabel>Price / KG</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'handling',
|
||||||
|
accessorKey: 'handlingcharges',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Handling' },
|
||||||
|
header: () => <SectionLabel>Handling</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CategoryChip icon={<MdPriceCheck size={12} />} label={getValue() || '—'} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 110,
|
||||||
|
minSize: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<Tooltip content="Edit rule">
|
||||||
|
<IconButton
|
||||||
|
label="Edit rule"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEditDialog(row.original)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Delete rule">
|
||||||
|
<IconButton
|
||||||
|
label="Delete rule"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={16} />}
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[pagination.pageIndex, pagination.pageSize, tenantMap, locationMap]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: PRICING_TABLE_FEATURES,
|
||||||
|
// `rows` is already useMemo'd above (stable reference) — required for
|
||||||
|
// pagination clicks to stick, see riders.js's fix for the same bug.
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.pricingid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />}
|
{(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />}
|
||||||
@@ -344,98 +559,181 @@ const ClientsPricing = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MobileCardList scroll>
|
<MobileCardList scroll>
|
||||||
{rows.map((row, index) => (
|
{table.getRowModel().rows.map((tableRow) => {
|
||||||
<MobileCard
|
const row = tableRow.original;
|
||||||
key={row.pricingid || `${row.tenantid}-${index}`}
|
return (
|
||||||
accent={BRAND}
|
<MobileCard
|
||||||
header={
|
key={tableRow.id}
|
||||||
<HStack justify="between" vAlign="center" gap={1}>
|
accent={BRAND}
|
||||||
<HStack gap={2} vAlign="center" minWidth={0}>
|
header={
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
<HStack justify="between" vAlign="center" gap={1}>
|
||||||
<MdGroups size={18} />
|
<HStack gap={2} vAlign="center" minWidth={0}>
|
||||||
</AccentAvatar>
|
<AccentAvatar color={BRAND} size={36}>
|
||||||
<VStack gap={0} padding={0} minWidth={0}>
|
<MdGroups size={18} />
|
||||||
<Text weight="bold" maxLines={1}>
|
</AccentAvatar>
|
||||||
{tenantName(row)}
|
<VStack gap={0} padding={0} minWidth={0}>
|
||||||
</Text>
|
<Text weight="bold" maxLines={1}>
|
||||||
<Text type="supporting" color="secondary">
|
{tenantName(row)}
|
||||||
ID #{row.pricingid}
|
</Text>
|
||||||
</Text>
|
<Text type="supporting" color="secondary">
|
||||||
</VStack>
|
ID #{row.pricingid}
|
||||||
</HStack>
|
</Text>
|
||||||
<HStack gap={0.5}>
|
</VStack>
|
||||||
<IconButton
|
</HStack>
|
||||||
label="Edit rule"
|
<HStack gap={0.5}>
|
||||||
tooltip="Edit rule"
|
<IconButton
|
||||||
size="sm"
|
label="Edit rule"
|
||||||
variant="ghost"
|
tooltip="Edit rule"
|
||||||
icon={<MdEdit size={14} />}
|
size="sm"
|
||||||
onClick={() => openEditDialog(row)}
|
variant="ghost"
|
||||||
/>
|
icon={<MdEdit size={14} />}
|
||||||
<IconButton
|
onClick={() => openEditDialog(row)}
|
||||||
label="Delete rule"
|
/>
|
||||||
tooltip="Delete rule"
|
<IconButton
|
||||||
size="sm"
|
label="Delete rule"
|
||||||
variant="destructive"
|
tooltip="Delete rule"
|
||||||
icon={<MdDeleteOutline size={14} />}
|
size="sm"
|
||||||
onClick={() => handleDelete(row)}
|
variant="destructive"
|
||||||
/>
|
icon={<MdDeleteOutline size={14} />}
|
||||||
|
onClick={() => handleDelete(row)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<HStack gap={1} wrap="wrap" style={{ marginTop: 8 }}>
|
||||||
|
<Badge variant="green" icon={<MdPlace size={12} />} label={zoneName(row)} />
|
||||||
|
<Badge variant="blue" icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
|
||||||
</HStack>
|
</HStack>
|
||||||
}
|
|
||||||
>
|
|
||||||
<HStack gap={1} wrap="wrap" style={{ marginTop: 8 }}>
|
|
||||||
<Badge variant="green" icon={<MdPlace size={12} />} label={zoneName(row)} />
|
|
||||||
<Badge variant="blue" icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
|
|
||||||
</HStack>
|
|
||||||
|
|
||||||
<MobileFieldGrid>
|
<MobileFieldGrid>
|
||||||
<MobileField label="Base Price">
|
<MobileField label="Base Price">
|
||||||
<MetricPill label={formatRupees(row.baseprice)} />
|
<MetricPill label={formatRupees(row.baseprice)} />
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<MobileField label="Price / KM">
|
<MobileField label="Price / KM">
|
||||||
<MetricPill label={formatRupees(row.priceperkm)} />
|
<MetricPill label={formatRupees(row.priceperkm)} />
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<MobileField label="Price / KG">
|
<MobileField label="Price / KG">
|
||||||
<MetricPill label={formatRupees(row.priceperkg)} />
|
<MetricPill label={formatRupees(row.priceperkg)} />
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<MobileField label="Handling">
|
<MobileField label="Handling">
|
||||||
<MetricPill label={formatRupees(row.handlingcharges)} />
|
<MetricPill label={formatRupees(row.handlingcharges)} />
|
||||||
</MobileField>
|
</MobileField>
|
||||||
<MobileField label="Status">
|
<MobileField label="Status">
|
||||||
<HStack gap={1} vAlign="center">
|
<HStack gap={1} vAlign="center">
|
||||||
<MdReceiptLong size={14} color={DT.textMuted} />
|
<MdReceiptLong size={14} color={DT.textMuted} />
|
||||||
<Text weight="bold">{row.status || '—'}</Text>
|
<Text weight="bold">{row.status || '—'}</Text>
|
||||||
</HStack>
|
</HStack>
|
||||||
</MobileField>
|
</MobileField>
|
||||||
</MobileFieldGrid>
|
</MobileFieldGrid>
|
||||||
</MobileCard>
|
</MobileCard>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={1260}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Pricing"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell style={{ minWidth: 240 }}>Tenant</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell style={{ minWidth: 160 }}>Zone</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell style={{ minWidth: 100 }}>Vehicle</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell style={{ minWidth: 120 }}>Base Price</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell style={{ minWidth: 120 }}>Price / KM</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell style={{ minWidth: 120 }}>Price / KG</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell style={{ minWidth: 100 }}>Handling</TableHeaderCell>
|
key={header.id}
|
||||||
<TableHeaderCell style={{ minWidth: 100 }}>Status</TableHeaderCell>
|
scope="col"
|
||||||
<TableHeaderCell style={{ minWidth: 100 }}>Actions</TableHeaderCell>
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
</TableRow>
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={10} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No pricing to show"
|
title="No pricing to show"
|
||||||
description={searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'}
|
description={searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'}
|
||||||
@@ -444,80 +742,19 @@ const ClientsPricing = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.pricingid || `${row.tenantid}-${index}`}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
<TableCell
|
||||||
{index + 1}
|
key={cell.id}
|
||||||
</Text>
|
style={{
|
||||||
</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell>
|
}}
|
||||||
<HStack gap={2} vAlign="center">
|
>
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<MdGroups size={18} />
|
</TableCell>
|
||||||
</AccentAvatar>
|
))}
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{tenantName(row)}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
ID #{row.pricingid}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<CategoryChip icon={<MdPlace size={12} />} label={zoneName(row)} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<CategoryChip icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<MetricPill label={formatRupees(row.baseprice)} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<MetricPill label={formatRupees(row.priceperkm)} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<MetricPill label={formatRupees(row.priceperkg)} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<MetricPill label={formatRupees(row.handlingcharges)} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<CategoryChip icon={<MdPriceCheck size={12} />} label={row.status || '—'} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<HStack gap={1} justify="end">
|
|
||||||
<Tooltip content="Edit rule">
|
|
||||||
<IconButton
|
|
||||||
label="Edit rule"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdEdit size={16} />}
|
|
||||||
onClick={() => openEditDialog(row)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content="Delete rule">
|
|
||||||
<IconButton
|
|
||||||
label="Delete rule"
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
icon={<MdDeleteOutline size={16} />}
|
|
||||||
onClick={() => handleDelete(row)}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -525,6 +762,30 @@ const ClientsPricing = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
rows.length
|
||||||
|
)} of ${rows.length} pricing rule${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(p) => table.setPageIndex(p - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Pricing pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
@@ -24,18 +24,45 @@ import {
|
|||||||
MdEdit,
|
MdEdit,
|
||||||
MdDeleteOutline,
|
MdDeleteOutline,
|
||||||
MdPeopleAlt,
|
MdPeopleAlt,
|
||||||
MdVisibility,
|
MdArrowUpward,
|
||||||
MdVisibilityOff,
|
MdArrowDownward,
|
||||||
MdBlock,
|
MdUnfoldMore,
|
||||||
MdClose
|
// Outline-only set for the Actions column — kept separate from the filled
|
||||||
|
// icons above (MdPlace/MdEdit/MdPeopleAlt), which stay filled for the
|
||||||
|
// Locations/Customers/Pricing dialogs elsewhere in this file. Mixing
|
||||||
|
// filled and outlined glyphs in one row is what read as inconsistent.
|
||||||
|
MdOutlineBlock,
|
||||||
|
MdOutlineVisibility,
|
||||||
|
MdOutlineVisibilityOff,
|
||||||
|
MdOutlineEdit,
|
||||||
|
MdOutlineClose,
|
||||||
|
MdOutlineMoreVert,
|
||||||
|
MdOutlinePlace,
|
||||||
|
MdOutlinePeopleAlt
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { LuMail } from 'react-icons/lu';
|
import { LuMail } from 'react-icons/lu';
|
||||||
import { BiUser } from 'react-icons/bi';
|
import { BiUser } from 'react-icons/bi';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js use, so sorting/
|
||||||
|
// resizing/pagination behave identically across the console's list pages.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -46,6 +73,7 @@ import { NumberInput } from '@astryxdesign/core/NumberInput';
|
|||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { TabList, Tab } from '@astryxdesign/core/TabList';
|
import { TabList, Tab } from '@astryxdesign/core/TabList';
|
||||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
|
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
@@ -103,6 +131,27 @@ const KPI_META = [
|
|||||||
|
|
||||||
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
|
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const TENANTS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's and riders.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
// ==============================|| Tenant locations (branches/sites) dialog ||============================== //
|
// ==============================|| Tenant locations (branches/sites) dialog ||============================== //
|
||||||
// Self-contained — deliberately does not touch the existing openRowIndex1/2 +
|
// Self-contained — deliberately does not touch the existing openRowIndex1/2 +
|
||||||
// Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT
|
// Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT
|
||||||
@@ -547,6 +596,14 @@ const Clients1 = () => {
|
|||||||
const [latlong, setLatlong] = useState({});
|
const [latlong, setLatlong] = useState({});
|
||||||
const [editClient, setEditClient] = useState({});
|
const [editClient, setEditClient] = useState({});
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
// Table sorting/resizing state — mirrors orders.js's/riders.js's slices.
|
||||||
|
// Pagination itself stays on the existing page/rowsPerPage state (below)
|
||||||
|
// rather than a new `pagination` object, since page/rowsPerPage already
|
||||||
|
// feed the tenantList query key and several onClick handlers — bridged
|
||||||
|
// into TanStack's { pageIndex, pageSize } shape via paginationState/
|
||||||
|
// handlePaginationChange further down.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const dialogclose = () => {
|
const dialogclose = () => {
|
||||||
setDialogopen(false);
|
setDialogopen(false);
|
||||||
@@ -822,15 +879,43 @@ const Clients1 = () => {
|
|||||||
|
|
||||||
const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active;
|
const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active;
|
||||||
|
|
||||||
const renderRowActions = (row, index, expanded, size = 'sm') => (
|
// Icon buttons only, kept to 3 max + one overflow menu: status action,
|
||||||
<HStack gap={0.75} justify="end">
|
// view details, edit, then "More" (Locations/Customers — secondary,
|
||||||
|
// low-frequency actions) behind a kebab menu.
|
||||||
|
//
|
||||||
|
// Sizing: `size="md"` is Astryx's own 32px button height (confirmed in
|
||||||
|
// Button's sizeStyles — sm=28px/md=32px/lg=36px) and icon-only buttons are
|
||||||
|
// always square, so this is a native 32×32px button, not a manually forced
|
||||||
|
// one. Its border-radius is Astryx's own default --radius-element (8px —
|
||||||
|
// see the identical note in dispatch/Dispatch.css), so no radius override
|
||||||
|
// is needed either — both requirements were already the design system's
|
||||||
|
// own defaults once the size prop was corrected.
|
||||||
|
//
|
||||||
|
// Icon family: every icon here is the Material "outline" variant
|
||||||
|
// (MdOutline*). Mixing MdBlock/MdVisibility/MdEdit/MdClose/MdMoreVert
|
||||||
|
// (filled) with MdOutlineCheckCircle (outline) — the state before this
|
||||||
|
// pass — is what actually read as inconsistent, not the icons' size or
|
||||||
|
// position. MdPlace/MdEdit/MdPeopleAlt stay filled where they're used
|
||||||
|
// elsewhere in this file (Locations/Customers/Pricing dialogs) — only this
|
||||||
|
// row switches to the Outline set.
|
||||||
|
//
|
||||||
|
// Colour: the status-action slot (Inactivate/Approve/Activate — whichever
|
||||||
|
// one applies to the active tab) is the one action with real consequence
|
||||||
|
// for the tenant's account state, so it keeps the `destructive` (red-
|
||||||
|
// tinted) treatment across all three tab variants for a consistent,
|
||||||
|
// predictable "this is the serious one" signal. View/Edit/More stay
|
||||||
|
// `ghost` (transparent, neutral) — `primary` only kicks in on View/Edit
|
||||||
|
// when their panel is the one currently open, an existing state indicator
|
||||||
|
// preserved as-is, not a new style.
|
||||||
|
const renderRowActions = (row, index, expanded, size = 'md') => (
|
||||||
|
<HStack gap={2} vAlign="center" justify="end" style={{ paddingRight: 'var(--spacing-4)' }}>
|
||||||
{value0 == 0 && (
|
{value0 == 0 && (
|
||||||
<IconButton
|
<IconButton
|
||||||
label="Inactivate"
|
label="Inactivate"
|
||||||
tooltip="Inactivate"
|
tooltip="Inactivate"
|
||||||
size={size}
|
size={size}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
icon={<MdBlock size={14} />}
|
icon={<MdOutlineBlock size={17} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCustomer(row);
|
setSelectedCustomer(row);
|
||||||
setSelectedtenid(row.tenantid);
|
setSelectedtenid(row.tenantid);
|
||||||
@@ -846,8 +931,8 @@ const Clients1 = () => {
|
|||||||
label="Approve"
|
label="Approve"
|
||||||
tooltip="Approve"
|
tooltip="Approve"
|
||||||
size={size}
|
size={size}
|
||||||
variant="ghost"
|
variant="destructive"
|
||||||
icon={<FaRegCheckCircle size={13} />}
|
icon={<MdOutlineCheckCircle size={17} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCustomer(row);
|
setSelectedCustomer(row);
|
||||||
setDialogopen(true);
|
setDialogopen(true);
|
||||||
@@ -862,8 +947,8 @@ const Clients1 = () => {
|
|||||||
label="Activate"
|
label="Activate"
|
||||||
tooltip="Activate"
|
tooltip="Activate"
|
||||||
size={size}
|
size={size}
|
||||||
variant="ghost"
|
variant="destructive"
|
||||||
icon={<FaRegCheckCircle size={13} />}
|
icon={<MdOutlineCheckCircle size={17} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCustomer(row);
|
setSelectedCustomer(row);
|
||||||
setSelectedtenid(row.tenantid);
|
setSelectedtenid(row.tenantid);
|
||||||
@@ -879,7 +964,7 @@ const Clients1 = () => {
|
|||||||
tooltip={expanded ? 'Hide details' : 'View details'}
|
tooltip={expanded ? 'Hide details' : 'View details'}
|
||||||
size={size}
|
size={size}
|
||||||
variant={expanded ? 'primary' : 'ghost'}
|
variant={expanded ? 'primary' : 'ghost'}
|
||||||
icon={expanded ? <MdVisibilityOff size={14} /> : <MdVisibility size={14} />}
|
icon={expanded ? <MdOutlineVisibilityOff size={17} /> : <MdOutlineVisibility size={17} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCustomer(row);
|
setSelectedCustomer(row);
|
||||||
handleCollapseToggle1(index);
|
handleCollapseToggle1(index);
|
||||||
@@ -887,29 +972,13 @@ const Clients1 = () => {
|
|||||||
setSelectedtenid(row.tenantid);
|
setSelectedtenid(row.tenantid);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<IconButton
|
|
||||||
label="Locations"
|
|
||||||
tooltip="Locations"
|
|
||||||
size={size}
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdPlace size={14} />}
|
|
||||||
onClick={() => setLocationsDialogTenant(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Customers"
|
|
||||||
tooltip="Customers"
|
|
||||||
size={size}
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdPeopleAlt size={14} />}
|
|
||||||
onClick={() => setCustomersDialogTenant(row)}
|
|
||||||
/>
|
|
||||||
{value0 !== 1 && (
|
{value0 !== 1 && (
|
||||||
<IconButton
|
<IconButton
|
||||||
label={openRowIndex2 === index ? 'Close' : 'Edit'}
|
label={openRowIndex2 === index ? 'Close' : 'Edit'}
|
||||||
tooltip={openRowIndex2 === index ? 'Close' : 'Edit'}
|
tooltip={openRowIndex2 === index ? 'Close' : 'Edit'}
|
||||||
size={size}
|
size={size}
|
||||||
variant={openRowIndex2 === index ? 'primary' : 'ghost'}
|
variant={openRowIndex2 === index ? 'primary' : 'ghost'}
|
||||||
icon={openRowIndex2 === index ? <MdClose size={14} /> : <MdEdit size={14} />}
|
icon={openRowIndex2 === index ? <MdOutlineClose size={17} /> : <MdOutlineEdit size={17} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedCustomer(row);
|
setSelectedCustomer(row);
|
||||||
handleCollapseToggle2(index);
|
handleCollapseToggle2(index);
|
||||||
@@ -919,6 +988,22 @@ const Clients1 = () => {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<DropdownMenu
|
||||||
|
button={{
|
||||||
|
label: 'More actions',
|
||||||
|
tooltip: 'More actions',
|
||||||
|
variant: 'ghost',
|
||||||
|
size,
|
||||||
|
icon: <MdOutlineMoreVert size={17} />,
|
||||||
|
isIconOnly: true
|
||||||
|
}}
|
||||||
|
menuWidth={180}
|
||||||
|
placement="below"
|
||||||
|
items={[
|
||||||
|
{ label: 'Locations', icon: <MdOutlinePlace size={16} />, onClick: () => setLocationsDialogTenant(row) },
|
||||||
|
{ label: 'Customers', icon: <MdOutlinePeopleAlt size={16} />, onClick: () => setCustomersDialogTenant(row) }
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1049,6 +1134,146 @@ const Clients1 = () => {
|
|||||||
</MainCard>
|
</MainCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Bridges TanStack's { pageIndex, pageSize } pagination shape onto the
|
||||||
|
// existing page/rowsPerPage state, so every other reader of those two
|
||||||
|
// values (the tenantList query key, handleChange, the page-size onChange
|
||||||
|
// below) keeps working unchanged.
|
||||||
|
const paginationState = { pageIndex: page, pageSize: rowsPerPage };
|
||||||
|
const handlePaginationChange = (updater) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(paginationState) : updater;
|
||||||
|
if (next.pageIndex !== page) setPage(next.pageIndex);
|
||||||
|
if (next.pageSize !== rowsPerPage) setRowsPerPage(next.pageSize);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as orders.js's
|
||||||
|
// and riders.js's `columns`. Deliberately NOT wrapped in useMemo: the
|
||||||
|
// Actions column's cell calls `renderRowActions`, which closes over value0/
|
||||||
|
// openRowIndex2/several setters that change often — memoizing here would
|
||||||
|
// need an unmanageable dependency list, and recomputing ~6 column defs a
|
||||||
|
// render is cheap. Row-expand state is keyed by `row.tenantid` (not
|
||||||
|
// position), so it stays correct once sorting/pagination reorder rows.
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(page * rowsPerPage + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const meta = STATUS_META[String(getValue() || '').toLowerCase()] || STATUS_META.active;
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return <Badge variant={meta.badge} icon={<Icon size={12} />} label={meta.label} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'client',
|
||||||
|
accessorKey: 'tenantname',
|
||||||
|
size: 260,
|
||||||
|
minSize: 200,
|
||||||
|
meta: { label: 'Client' },
|
||||||
|
header: () => <SectionLabel>Client</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={1.5} vAlign="center">
|
||||||
|
<AccentAvatar color={BRAND} selected size={32}>
|
||||||
|
<MdPersonPin size={16} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
ID #{row.original.tenantid}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
accessorKey: 'primarycontact',
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Contact' },
|
||||||
|
header: () => <SectionLabel>Contact</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{row.original.primaryemail || '—'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deliveryOtp',
|
||||||
|
accessorFn: (row) => (row.requiredeliveryotp ? 'Yes' : 'No'),
|
||||||
|
size: 150,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Delivery OTP', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Delivery OTP</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
// Up to 4 controls: status action + view details + edit + a "More"
|
||||||
|
// overflow menu (Locations/Customers) — wide enough that 32px buttons
|
||||||
|
// with 8px gaps never wrap to a second line.
|
||||||
|
size: 220,
|
||||||
|
minSize: 190,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => renderRowActions(row.original, row.original.tenantid, openRowIndex1 === row.original.tenantid)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: TENANTS_TABLE_FEATURES,
|
||||||
|
// filteredTenantList is already useMemo'd above (stable reference across
|
||||||
|
// renders) — required for TanStack's row-model memoization to actually
|
||||||
|
// hold and for pagination clicks to stick (an unmemoized array here
|
||||||
|
// reads as "data changed" on every render and auto-resets to page 1,
|
||||||
|
// the exact bug fixed on riders.js's table).
|
||||||
|
data: filteredTenantList,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.tenantid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination: paginationState, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: handlePaginationChange,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(getalltenantsIsLoading || isloader) && <Loader />}
|
{(getalltenantsIsLoading || isloader) && <Loader />}
|
||||||
@@ -1140,24 +1365,108 @@ const Clients1 = () => {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
<TableScroll minWidth={960}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's
|
||||||
|
tables. Header and body are both rendered FROM the column
|
||||||
|
model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Tenants"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Client</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Contact</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Delivery OTP</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
return (
|
||||||
</TableRow>
|
<TableHeaderCell
|
||||||
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{getalltenantsIsLoading && <TableLoader rows={5} columns={6} />}
|
{getalltenantsIsLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
|
|
||||||
{filteredTenantList.length == 0 && !isloader ? (
|
{filteredTenantList.length == 0 && !isloader ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No tenants to show"
|
title="No tenants to show"
|
||||||
description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
|
description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
|
||||||
@@ -1166,65 +1475,37 @@ const Clients1 = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredTenantList.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row, index) => {
|
table.getRowModel().rows.map((tableRow) => {
|
||||||
const rowStatusKey = String(row.status || '').toLowerCase();
|
const row = tableRow.original;
|
||||||
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
|
const expanded1 = openRowIndex1 === row.tenantid;
|
||||||
const RowStatusIcon = rowStatusMeta.icon;
|
const expanded2 = openRowIndex2 === row.tenantid;
|
||||||
return (
|
return (
|
||||||
<Fragment key={row.tenantid ?? index}>
|
<Fragment key={tableRow.id}>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
<TableCell
|
||||||
{String(index + 1 + page * rowsPerPage).padStart(2, '0')}
|
key={cell.id}
|
||||||
</Text>
|
style={{
|
||||||
</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={12} />} label={rowStatusMeta.label} />
|
}}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<HStack gap={1.5} vAlign="center">
|
</TableCell>
|
||||||
<AccentAvatar color={BRAND} selected size={32}>
|
))}
|
||||||
<MdPersonPin size={16} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{row.tenantname}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
ID #{row.tenantid}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{row.primarycontact || '—'}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.primaryemail || '—'}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.requiredeliveryotp ? 'Yes' : 'No'}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{renderRowActions(row, index, openRowIndex1 === index)}</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{openRowIndex1 === index && (
|
{expanded1 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} style={{ padding: 12 }}>
|
<TableCell colSpan={colCount} style={{ padding: 12 }}>
|
||||||
{renderDetailsPanel(row)}
|
{renderDetailsPanel(row)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{openRowIndex2 === index && (
|
{expanded2 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6} style={{ padding: 12 }}>
|
<TableCell colSpan={colCount} style={{ padding: 12 }}>
|
||||||
{renderEditPanel(row)}
|
{renderEditPanel(row)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -1247,14 +1528,15 @@ const Clients1 = () => {
|
|||||||
icon={<MdGroups size={28} />}
|
icon={<MdGroups size={28} />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredTenantList.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row, index) => {
|
table.getRowModel().rows.map((tableRow) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const rowStatusKey = String(row.status || '').toLowerCase();
|
const rowStatusKey = String(row.status || '').toLowerCase();
|
||||||
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
|
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
|
||||||
const RowStatusIcon = rowStatusMeta.icon;
|
const RowStatusIcon = rowStatusMeta.icon;
|
||||||
const expanded = openRowIndex1 === index;
|
const expanded = openRowIndex1 === row.tenantid;
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={row.tenantid ?? index}
|
key={tableRow.id}
|
||||||
accent={rowStatusMeta.color}
|
accent={rowStatusMeta.color}
|
||||||
header={
|
header={
|
||||||
<HStack justify="between" vAlign="start" gap={1}>
|
<HStack justify="between" vAlign="start" gap={1}>
|
||||||
@@ -1269,7 +1551,7 @@ const Clients1 = () => {
|
|||||||
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={10} />} label={rowStatusMeta.label} />
|
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={10} />} label={rowStatusMeta.label} />
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
{renderRowActions(row, index, expanded)}
|
{renderRowActions(row, row.tenantid, expanded)}
|
||||||
</HStack>
|
</HStack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
@@ -1294,10 +1576,20 @@ const Clients1 = () => {
|
|||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!searchword && filteredTenantList.length > 0 && (
|
{/* Page-number pagination, matching orders.js's/riders.js's
|
||||||
|
footer — previously hidden while `searchword` was set (a
|
||||||
|
search result past the first page had no way to reach it),
|
||||||
|
and used the "count"-only variant (no page-number buttons)
|
||||||
|
instead of the numbered pager the other two tables use. */}
|
||||||
|
{filteredTenantList.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<HStack justify="end" padding={3}>
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${page * rowsPerPage + 1}–${Math.min((page + 1) * rowsPerPage, filteredTenantList.length)} of ${
|
||||||
|
filteredTenantList.length
|
||||||
|
} tenant${filteredTenantList.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
<Pagination
|
<Pagination
|
||||||
page={page + 1}
|
page={page + 1}
|
||||||
onChange={(p) => setPage(p - 1)}
|
onChange={(p) => setPage(p - 1)}
|
||||||
@@ -1308,7 +1600,8 @@ const Clients1 = () => {
|
|||||||
setRowsPerPage(size);
|
setRowsPerPage(size);
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}}
|
}}
|
||||||
variant="count"
|
size="sm"
|
||||||
|
label="Tenants pagination"
|
||||||
/>
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md';
|
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -75,10 +89,41 @@ const emptyBranch = {
|
|||||||
const emptyCarrier = { id: null, company: '', weight_slab: '', service_type: '', zone: '', rate: '', delivery_time: '' };
|
const emptyCarrier = { id: null, company: '', weight_slab: '', service_type: '', zone: '', rate: '', delivery_time: '' };
|
||||||
const PAGE_SIZE = 50;
|
const PAGE_SIZE = 50;
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — sorting/resizing/
|
||||||
|
// pagination, same as every other table converted this session. No
|
||||||
|
// `paginatedRowModel` factory: this page's pages are fetched from the
|
||||||
|
// server (GET /admin/competitor-branches|carrier-pricing, page/PAGE_SIZE
|
||||||
|
// query params), not client-sliced, so the table runs in `manualPagination`
|
||||||
|
// mode — `data` IS the current page already, and TanStack skips its own
|
||||||
|
// slicing whenever `manualPagination: true` regardless of a registered
|
||||||
|
// row-model factory (confirmed by reading table-core's
|
||||||
|
// table_getPaginatedRowModel).
|
||||||
|
const COMPETITIVE_INTEL_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const CompetitiveIntel = () => {
|
const CompetitiveIntel = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [tab, setTab] = useState('branches');
|
const [tab, setTab] = useState('branches');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
|
// Table sorting/resizing state — mirrors orders.js's/riders.js's slices.
|
||||||
|
// Pagination itself stays on `page` above (bridged onto TanStack's shape
|
||||||
|
// further down) since it drives the server-side fetch.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [branchForm, setBranchForm] = useState(emptyBranch);
|
const [branchForm, setBranchForm] = useState(emptyBranch);
|
||||||
const [carrierForm, setCarrierForm] = useState(emptyCarrier);
|
const [carrierForm, setCarrierForm] = useState(emptyCarrier);
|
||||||
@@ -86,6 +131,7 @@ const CompetitiveIntel = () => {
|
|||||||
const changeTab = (t) => {
|
const changeTab = (t) => {
|
||||||
setTab(t);
|
setTab(t);
|
||||||
setPage(1);
|
setPage(1);
|
||||||
|
setSorting([]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const { data: branchesRes, isLoading: branchesLoading } = useQuery({
|
const { data: branchesRes, isLoading: branchesLoading } = useQuery({
|
||||||
@@ -224,6 +270,217 @@ const CompetitiveIntel = () => {
|
|||||||
const setBranch = (key) => (value) => setBranchForm((f) => ({ ...f, [key]: value }));
|
const setBranch = (key) => (value) => setBranchForm((f) => ({ ...f, [key]: value }));
|
||||||
const setCarrier = (key) => (value) => setCarrierForm((f) => ({ ...f, [key]: value }));
|
const setCarrier = (key) => (value) => setCarrierForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// `rows` above is a fresh array per render (`branches`/`carrierPricing`
|
||||||
|
// themselves come straight off useQuery's `data`, which React Query keeps
|
||||||
|
// referentially stable across renders when the response is unchanged) —
|
||||||
|
// still wrapped here for a guaranteed-stable reference, matching the fix
|
||||||
|
// applied on riders.js so pagination clicks can't hit the auto-reset bug.
|
||||||
|
const tableRows = useMemo(() => rows, [rows]);
|
||||||
|
|
||||||
|
// Bridges TanStack's { pageIndex, pageSize } pagination shape onto the
|
||||||
|
// existing 1-based `page` state, which is the actual source of truth (it
|
||||||
|
// drives the server-side fetch via the query key).
|
||||||
|
const paginationState = { pageIndex: page - 1, pageSize: PAGE_SIZE };
|
||||||
|
const handlePaginationChange = (updater) => {
|
||||||
|
const next = typeof updater === 'function' ? updater(paginationState) : updater;
|
||||||
|
if (next.pageIndex !== page - 1) setPage(next.pageIndex + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The column model switches with `tab` — branches and pricing are two
|
||||||
|
// logically different tables sharing one <Table>. Deliberately NOT
|
||||||
|
// wrapped in useMemo: the Actions column's cell closes over `openEdit`/
|
||||||
|
// `handleDelete`, which close over `tab` themselves — memoizing here would
|
||||||
|
// need `tab` (and effectively everything) in the dep list anyway, and
|
||||||
|
// recomputing ~6-7 column defs a render is cheap (same reasoning as
|
||||||
|
// Tenants.js's `columns`).
|
||||||
|
const columns =
|
||||||
|
tab === 'branches'
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 50,
|
||||||
|
minSize: 40,
|
||||||
|
maxSize: 60,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (page - 1) * PAGE_SIZE + t.getRowModel().rows.indexOf(row) + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'company',
|
||||||
|
accessorKey: 'company',
|
||||||
|
size: 200,
|
||||||
|
minSize: 140,
|
||||||
|
meta: { label: 'Company' },
|
||||||
|
header: () => <SectionLabel>Company</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'area',
|
||||||
|
accessorKey: 'area',
|
||||||
|
size: 160,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Area' },
|
||||||
|
header: () => <SectionLabel>Area</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'phone',
|
||||||
|
accessorKey: 'phone',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Phone' },
|
||||||
|
header: () => <SectionLabel>Phone</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
accessorKey: 'address',
|
||||||
|
size: 240,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Address' },
|
||||||
|
header: () => <SectionLabel>Address</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate_per_kg',
|
||||||
|
accessorKey: 'rate_per_kg',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Rate/Kg' },
|
||||||
|
header: () => <SectionLabel>Rate/Kg</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Edit"
|
||||||
|
tooltip="Edit"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Delete"
|
||||||
|
tooltip="Delete"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={16} />}
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 50,
|
||||||
|
minSize: 40,
|
||||||
|
maxSize: 60,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (page - 1) * PAGE_SIZE + t.getRowModel().rows.indexOf(row) + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'company',
|
||||||
|
accessorKey: 'company',
|
||||||
|
size: 200,
|
||||||
|
minSize: 140,
|
||||||
|
meta: { label: 'Company' },
|
||||||
|
header: () => <SectionLabel>Company</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'weight_slab',
|
||||||
|
accessorKey: 'weight_slab',
|
||||||
|
size: 160,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Weight Slab' },
|
||||||
|
header: () => <SectionLabel>Weight Slab</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'zone',
|
||||||
|
accessorKey: 'zone',
|
||||||
|
size: 160,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Zone' },
|
||||||
|
header: () => <SectionLabel>Zone</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rate',
|
||||||
|
accessorKey: 'rate',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Rate' },
|
||||||
|
header: () => <SectionLabel>Rate</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Edit"
|
||||||
|
tooltip="Edit"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Delete"
|
||||||
|
tooltip="Delete"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={16} />}
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: COMPETITIVE_INTEL_TABLE_FEATURES,
|
||||||
|
data: tableRows,
|
||||||
|
columns,
|
||||||
|
manualPagination: true,
|
||||||
|
pageCount,
|
||||||
|
getRowId: (row, index) => String(row.id ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination: paginationState, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: handlePaginationChange,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
|
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
|
||||||
@@ -259,35 +516,107 @@ const CompetitiveIntel = () => {
|
|||||||
</TabList>
|
</TabList>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableScroll minWidth={tab === 'branches' ? 920 : 780}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Competitive Intel"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
{tab === 'branches' ? (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableRow>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Company</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Area</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Phone</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Address</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>Rate/Kg</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
))}
|
||||||
<TableRow>
|
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Company</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Weight Slab</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Zone</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Rate</TableHeaderCell>
|
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={7} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={7}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No records to show"
|
title="No records to show"
|
||||||
description="Add a record to start benchmarking against it."
|
description="Add a record to start benchmarking against it."
|
||||||
@@ -296,49 +625,19 @@ const CompetitiveIntel = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.id || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
{tab === 'branches' ? (
|
<TableCell
|
||||||
<>
|
key={cell.id}
|
||||||
<TableCell>{row.company || '—'}</TableCell>
|
style={{
|
||||||
<TableCell>{row.area || '—'}</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>{row.phone || '—'}</TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell style={{ maxWidth: 220 }}>
|
}}
|
||||||
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.address}>
|
>
|
||||||
{row.address || '—'}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</Text>
|
</TableCell>
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell>{row.rate_per_kg || '—'}</TableCell>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<TableCell>{row.company || '—'}</TableCell>
|
|
||||||
<TableCell>{row.weight_slab || '—'}</TableCell>
|
|
||||||
<TableCell>{row.zone || '—'}</TableCell>
|
|
||||||
<TableCell>{row.rate || '—'}</TableCell>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<TableCell>
|
|
||||||
<HStack gap={1} justify="end">
|
|
||||||
<IconButton
|
|
||||||
label="Edit"
|
|
||||||
tooltip="Edit"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdEdit size={16} />}
|
|
||||||
onClick={() => openEdit(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Delete"
|
|
||||||
tooltip="Delete"
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
icon={<MdDeleteOutline size={16} />}
|
|
||||||
onClick={() => handleDelete(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -350,7 +649,7 @@ const CompetitiveIntel = () => {
|
|||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<HStack justify="center" paddingBlock={2}>
|
<HStack justify="center" paddingBlock={2}>
|
||||||
<Pagination page={page} onChange={setPage} totalPages={pageCount} />
|
<Pagination page={page} onChange={setPage} totalPages={pageCount} size="sm" label="Competitive intel pagination" />
|
||||||
</HStack>
|
</HStack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
@@ -11,12 +11,31 @@ import {
|
|||||||
MdOutlineMailOutline,
|
MdOutlineMailOutline,
|
||||||
MdOutlinePhoneAndroid,
|
MdOutlinePhoneAndroid,
|
||||||
MdPersonAddAlt1,
|
MdPersonAddAlt1,
|
||||||
MdOutlineReceiptLong
|
MdOutlineReceiptLong,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -26,6 +45,7 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
|||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { Divider } from '@astryxdesign/core/Divider';
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
@@ -64,6 +84,29 @@ const custPhone = (row) => row.phone || '—';
|
|||||||
const custEmail = (row) => row.email || '—';
|
const custEmail = (row) => row.email || '—';
|
||||||
const custBookings = (row) => row.totalbookings ?? 0;
|
const custBookings = (row) => row.totalbookings ?? 0;
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const CUSTOMERS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const CUSTOMERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const Customers = () => {
|
const Customers = () => {
|
||||||
const isMobile = useIsNarrowViewport();
|
const isMobile = useIsNarrowViewport();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -75,6 +118,9 @@ const Customers = () => {
|
|||||||
const [addressInput, setAddressInput] = useState('');
|
const [addressInput, setAddressInput] = useState('');
|
||||||
const [pickAddress, setPickAddress] = useState({});
|
const [pickAddress, setPickAddress] = useState({});
|
||||||
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
|
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
|
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
|
||||||
|
|
||||||
@@ -158,6 +204,132 @@ const Customers = () => {
|
|||||||
setAddressInput(place.formatted_address);
|
setAddressInput(place.formatted_address);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A search re-scopes the list, so page 1 is the only page that still
|
||||||
|
// means anything — same reset orders.js/riders.js apply.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as orders.js's/
|
||||||
|
// riders.js's/Tenants.js's `columns`. Not wrapped in useMemo: the Actions
|
||||||
|
// column's cell calls `openEdit`, which is cheap to recompute per render
|
||||||
|
// and safe here since it closes only over stable setState functions.
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customer',
|
||||||
|
accessorFn: (row) => custName(row),
|
||||||
|
size: 260,
|
||||||
|
minSize: 200,
|
||||||
|
meta: { label: 'Customer' },
|
||||||
|
header: () => <SectionLabel>Customer</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<AccentAvatar color={BRAND} size={32}>
|
||||||
|
<MdPeopleAlt size={16} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="bold">{getValue()}</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
ID #{custId(row.original) ?? '—'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
accessorFn: (row) => custPhone(row),
|
||||||
|
size: 240,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Contact' },
|
||||||
|
header: () => <SectionLabel>Contact</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<MdPhone size={12} color={DT.textMuted} />
|
||||||
|
<Text weight="bold">{getValue()}</Text>
|
||||||
|
</HStack>
|
||||||
|
{row.original.email && (
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<MdMail size={12} color={DT.textMuted} />
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{row.original.email}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bookings',
|
||||||
|
accessorFn: (row) => custBookings(row),
|
||||||
|
size: 130,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Bookings' },
|
||||||
|
header: () => <SectionLabel>Bookings</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text hasTabularNumbers>{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Edit customer"
|
||||||
|
tooltip="Edit customer"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: CUSTOMERS_TABLE_FEATURES,
|
||||||
|
// `rows` is already useMemo'd above (stable reference across renders) —
|
||||||
|
// required for TanStack's row-model memoization to hold and for
|
||||||
|
// pagination clicks to stick (an unmemoized array reads as "data
|
||||||
|
// changed" on every render and auto-resets to page 1).
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(custId(row) ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isLoading && <Loader />}
|
{isLoading && <Loader />}
|
||||||
@@ -224,62 +396,150 @@ const Customers = () => {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<MobileCardList scroll>
|
<MobileCardList scroll>
|
||||||
{rows.map((row, index) => (
|
{table.getRowModel().rows.map((tableRow) => {
|
||||||
<MobileCard
|
const row = tableRow.original;
|
||||||
key={custId(row) || index}
|
return (
|
||||||
accent={BRAND}
|
<MobileCard
|
||||||
header={
|
key={tableRow.id}
|
||||||
<HStack justify="between" vAlign="center" gap={1}>
|
accent={BRAND}
|
||||||
<HStack gap={2} vAlign="center" minWidth={0}>
|
header={
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
<HStack justify="between" vAlign="center" gap={1}>
|
||||||
<MdPeopleAlt size={18} />
|
<HStack gap={2} vAlign="center" minWidth={0}>
|
||||||
</AccentAvatar>
|
<AccentAvatar color={BRAND} size={36}>
|
||||||
<VStack gap={0} padding={0} minWidth={0}>
|
<MdPeopleAlt size={18} />
|
||||||
<Text weight="bold" maxLines={1}>
|
</AccentAvatar>
|
||||||
{custName(row)}
|
<VStack gap={0} padding={0} minWidth={0}>
|
||||||
</Text>
|
<Text weight="bold" maxLines={1}>
|
||||||
<Text type="supporting" color="secondary">
|
{custName(row)}
|
||||||
ID #{custId(row) ?? '—'}
|
</Text>
|
||||||
</Text>
|
<Text type="supporting" color="secondary">
|
||||||
</VStack>
|
ID #{custId(row) ?? '—'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
<IconButton
|
||||||
|
label="Edit customer"
|
||||||
|
tooltip="Edit customer"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={14} />}
|
||||||
|
onClick={() => openEdit(row)}
|
||||||
|
/>
|
||||||
</HStack>
|
</HStack>
|
||||||
<IconButton
|
}
|
||||||
label="Edit customer"
|
>
|
||||||
tooltip="Edit customer"
|
<MobileFieldGrid>
|
||||||
size="sm"
|
<MobileField label="Phone" value={custPhone(row)} />
|
||||||
variant="ghost"
|
<MobileField label="Email" value={custEmail(row)} />
|
||||||
icon={<MdEdit size={14} />}
|
<MobileField label="Bookings" value={custBookings(row)} />
|
||||||
onClick={() => openEdit(row)}
|
</MobileFieldGrid>
|
||||||
/>
|
</MobileCard>
|
||||||
</HStack>
|
);
|
||||||
}
|
})}
|
||||||
>
|
|
||||||
<MobileFieldGrid>
|
|
||||||
<MobileField label="Phone" value={custPhone(row)} />
|
|
||||||
<MobileField label="Email" value={custEmail(row)} />
|
|
||||||
<MobileField label="Bookings" value={custBookings(row)} />
|
|
||||||
</MobileFieldGrid>
|
|
||||||
</MobileCard>
|
|
||||||
))}
|
|
||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={720}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Customers"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Customer</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Contact</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Bookings</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
</TableRow>
|
return (
|
||||||
|
<TableHeaderCell
|
||||||
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={5} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No customers to show"
|
title="No customers to show"
|
||||||
description="Customers appear here once they've placed an order."
|
description="Customers appear here once they've placed an order."
|
||||||
@@ -288,55 +548,19 @@ const Customers = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={custId(row) || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
<TableCell
|
||||||
{String(index + 1).padStart(2, '0')}
|
key={cell.id}
|
||||||
</Text>
|
style={{
|
||||||
</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<HStack gap={2} vAlign="center">
|
}}
|
||||||
<AccentAvatar color={BRAND} size={32}>
|
>
|
||||||
<MdPeopleAlt size={16} />
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</AccentAvatar>
|
</TableCell>
|
||||||
<VStack gap={0} padding={0}>
|
))}
|
||||||
<Text weight="bold">{custName(row)}</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
ID #{custId(row) ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<HStack gap={1} vAlign="center">
|
|
||||||
<MdPhone size={12} color={DT.textMuted} />
|
|
||||||
<Text weight="bold">{custPhone(row)}</Text>
|
|
||||||
</HStack>
|
|
||||||
{row.email && (
|
|
||||||
<HStack gap={1} vAlign="center">
|
|
||||||
<MdMail size={12} color={DT.textMuted} />
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.email}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{custBookings(row)}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<HStack justify="end">
|
|
||||||
<IconButton
|
|
||||||
label="Edit customer"
|
|
||||||
tooltip="Edit customer"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdEdit size={16} />}
|
|
||||||
onClick={() => openEdit(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
@@ -344,6 +568,30 @@ const Customers = () => {
|
|||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
rows.length
|
||||||
|
)} of ${rows.length} customer${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={CUSTOMERS_PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Customers pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
@@ -1,20 +1,35 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { MdWarningAmber, MdAdd, MdCheckCircleOutline } from 'react-icons/md';
|
import { MdWarningAmber, MdAdd, MdCheckCircleOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
@@ -30,6 +45,29 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
|||||||
|
|
||||||
const BRAND = DT.brand;
|
const BRAND = DT.brand;
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, matching
|
||||||
|
// orders.js/riders.js/Tenants.js's tables).
|
||||||
|
const EXCEPTIONS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
|
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
|
||||||
const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') }));
|
const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') }));
|
||||||
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
|
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
|
||||||
@@ -51,6 +89,11 @@ const Exceptions = () => {
|
|||||||
const [resolveRow, setResolveRow] = useState(null);
|
const [resolveRow, setResolveRow] = useState(null);
|
||||||
const [resolution, setResolution] = useState('');
|
const [resolution, setResolution] = useState('');
|
||||||
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
|
// slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions });
|
const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions });
|
||||||
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||||
@@ -123,6 +166,132 @@ const Exceptions = () => {
|
|||||||
|
|
||||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as orders.js's
|
||||||
|
// / riders.js's / Tenants.js's `columns`.
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'consignment',
|
||||||
|
accessorKey: 'consignmentid',
|
||||||
|
size: 170,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Consignment' },
|
||||||
|
header: () => <SectionLabel>Consignment</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hub',
|
||||||
|
accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'),
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Hub' },
|
||||||
|
header: () => <SectionLabel>Hub</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'type',
|
||||||
|
accessorKey: 'exceptiontype',
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Type' },
|
||||||
|
header: () => <SectionLabel>Type</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'severity',
|
||||||
|
accessorKey: 'severity',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Severity' },
|
||||||
|
header: () => <SectionLabel>Severity</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Badge variant={SEVERITY_BADGE[getValue()] || 'neutral'} label={getValue()} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'description',
|
||||||
|
accessorKey: 'description',
|
||||||
|
size: 260,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Description' },
|
||||||
|
header: () => <SectionLabel>Description</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row) => row.status || 'Open',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) =>
|
||||||
|
!['Resolved', 'Closed'].includes(row.original.status) ? (
|
||||||
|
<HStack justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Resolve / Close"
|
||||||
|
tooltip="Resolve / Close"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdCheckCircleOutline size={16} />}
|
||||||
|
onClick={() => setResolveRow(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[hubMap, pagination.pageIndex, pagination.pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: EXCEPTIONS_TABLE_FEATURES,
|
||||||
|
// `rows` is already useMemo'd above (stable reference across renders) —
|
||||||
|
// required for TanStack's row-model memoization to hold and for
|
||||||
|
// pagination clicks to stick (an unmemoized array reads as "data
|
||||||
|
// changed" on every render and auto-resets to page 1).
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.exceptionid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
|
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
|
||||||
@@ -163,25 +332,107 @@ const Exceptions = () => {
|
|||||||
</FilterToolbar>
|
</FilterToolbar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableScroll minWidth={1020}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Exceptions"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Consignment</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Hub</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Type</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Severity</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Description</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
key={header.id}
|
||||||
</TableRow>
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={8} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No exceptions logged"
|
title="No exceptions logged"
|
||||||
description="Log lost, damaged or misrouted parcels here to track resolution."
|
description="Log lost, damaged or misrouted parcels here to track resolution."
|
||||||
@@ -190,41 +441,49 @@ const Exceptions = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.exceptionid || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{index + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>{row.consignmentid}</TableCell>
|
<TableCell
|
||||||
<TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell>
|
key={cell.id}
|
||||||
<TableCell>{row.exceptiontype}</TableCell>
|
style={{
|
||||||
<TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<Badge variant={SEVERITY_BADGE[row.severity] || 'neutral'} label={row.severity} />
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
</TableCell>
|
}}
|
||||||
<TableCell style={{ maxWidth: 220 }}>
|
>
|
||||||
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.description}>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
{row.description}
|
</TableCell>
|
||||||
</Text>
|
))}
|
||||||
</TableCell>
|
|
||||||
<TableCell>{row.status || 'Open'}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{!['Resolved', 'Closed'].includes(row.status) && (
|
|
||||||
<HStack justify="end">
|
|
||||||
<IconButton
|
|
||||||
label="Resolve / Close"
|
|
||||||
tooltip="Resolve / Close"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdCheckCircleOutline size={16} />}
|
|
||||||
onClick={() => setResolveRow(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
rows.length
|
||||||
|
)} of ${rows.length} exception${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Exceptions pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace, MdOutlineCheckCircle } from 'react-icons/md';
|
import {
|
||||||
|
MdOutlineWarehouse,
|
||||||
|
MdEdit,
|
||||||
|
MdDeleteOutline,
|
||||||
|
MdAdd,
|
||||||
|
MdPlace,
|
||||||
|
MdOutlineCheckCircle,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
|
} from 'react-icons/md';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -16,6 +41,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
|
|||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
@@ -41,6 +67,29 @@ const STATUS_OPTIONS = [
|
|||||||
{ value: 'Inactive', label: 'Inactive' }
|
{ value: 'Inactive', label: 'Inactive' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const HUBS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const HUBS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
hubid: null,
|
hubid: null,
|
||||||
hubname: '',
|
hubname: '',
|
||||||
@@ -63,6 +112,11 @@ const Hubs = () => {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
|
// /Tenants.js's slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||||
|
|
||||||
@@ -156,6 +210,138 @@ const Hubs = () => {
|
|||||||
|
|
||||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// A search change re-scopes the list, so page 1 is the only page that
|
||||||
|
// still means anything.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as orders.js's
|
||||||
|
// /riders.js's/Tenants.js's `columns`.
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 48,
|
||||||
|
minSize: 40,
|
||||||
|
maxSize: 56,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hubname',
|
||||||
|
accessorKey: 'hubname',
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Hub Name' },
|
||||||
|
header: () => <SectionLabel>Hub Name</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hubtype',
|
||||||
|
accessorFn: (row) => (row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'),
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Type' },
|
||||||
|
header: () => <SectionLabel>Type</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'city',
|
||||||
|
accessorKey: 'applocationid',
|
||||||
|
size: 100,
|
||||||
|
minSize: 80,
|
||||||
|
meta: { label: 'City' },
|
||||||
|
header: () => <SectionLabel>City</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() ?? '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
accessorKey: 'address',
|
||||||
|
size: 240,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Address' },
|
||||||
|
header: () => <SectionLabel>Address</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pincode',
|
||||||
|
accessorKey: 'pincode',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Pincode' },
|
||||||
|
header: () => <SectionLabel>Pincode</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (getValue() ? <StatusBadge status={getValue()} /> : '—')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Edit hub"
|
||||||
|
tooltip="Edit hub"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Delete hub"
|
||||||
|
tooltip="Delete hub"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={16} />}
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[pagination.pageIndex, pagination.pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: HUBS_TABLE_FEATURES,
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.hubid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || deleteMutation.isPending) && <Loader />}
|
{(isLoading || deleteMutation.isPending) && <Loader />}
|
||||||
@@ -196,25 +382,107 @@ const Hubs = () => {
|
|||||||
</FilterToolbar>
|
</FilterToolbar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableScroll minWidth={960}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Hubs"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Hub Name</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Type</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>City</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Address</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Pincode</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
key={header.id}
|
||||||
</TableRow>
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={8} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No hubs to show"
|
title="No hubs to show"
|
||||||
description="Create a hub to start routing consignments through it."
|
description="Create a hub to start routing consignments through it."
|
||||||
@@ -223,43 +491,49 @@ const Hubs = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.hubid || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{index + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>
|
<TableCell
|
||||||
<Text weight="bold">{row.hubname}</Text>
|
key={cell.id}
|
||||||
</TableCell>
|
style={{
|
||||||
<TableCell>{row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>{row.applocationid ?? '—'}</TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell>{row.address || '—'}</TableCell>
|
}}
|
||||||
<TableCell>{row.pincode || '—'}</TableCell>
|
>
|
||||||
<TableCell>{row.status ? <StatusBadge status={row.status} /> : '—'}</TableCell>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<HStack gap={1} justify="end">
|
))}
|
||||||
<IconButton
|
|
||||||
label="Edit hub"
|
|
||||||
tooltip="Edit hub"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdEdit size={16} />}
|
|
||||||
onClick={() => openEdit(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Delete hub"
|
|
||||||
tooltip="Delete hub"
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
icon={<MdDeleteOutline size={16} />}
|
|
||||||
onClick={() => handleDelete(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
rows.length
|
||||||
|
)} of ${rows.length} hub${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={HUBS_PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Hubs pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
@@ -2,13 +2,29 @@ import { Fragment, useEffect, useMemo, useState } from 'react';
|
|||||||
import { useLocation, useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { MdDirectionsBike, MdArrowBack } from 'react-icons/md';
|
import { MdDirectionsBike, MdArrowBack, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||||
import { FaMapLocationDot } from 'react-icons/fa6';
|
import { FaMapLocationDot } from 'react-icons/fa6';
|
||||||
import { HiOutlineArrowLeft } from 'react-icons/hi';
|
import { HiOutlineArrowLeft } from 'react-icons/hi';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing behave identically across the console's list pages.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -28,7 +44,7 @@ import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider }
|
|||||||
import { OpenToast } from 'components/third-party/OpenToast';
|
import { OpenToast } from 'components/third-party/OpenToast';
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
|
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
|
||||||
import { STATUS } from 'themes/dt/tokens';
|
import { DT, STATUS } from 'themes/dt/tokens';
|
||||||
import { TableScroll } from 'themes/dt/primitives';
|
import { TableScroll } from 'themes/dt/primitives';
|
||||||
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
@@ -36,125 +52,154 @@ dayjs.extend(utc);
|
|||||||
|
|
||||||
const orderTypeBadge = (type) => (type === 'Economy' ? 'green' : type === 'Risky' ? 'error' : 'blue');
|
const orderTypeBadge = (type) => (type === 'Economy' ? 'green' : type === 'Risky' ? 'error' : 'blue');
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — sorting/resizing ship
|
||||||
|
// here like every other converted page. rowPaginationFeature is registered
|
||||||
|
// too (table.getRowModel() always resolves through getPaginatedRowModel()
|
||||||
|
// internally), but this page deliberately never surfaces pagination UI —
|
||||||
|
// see the `pagination` state below for why.
|
||||||
|
const ORDERS_PREVIEW_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
// Mobile-only rendering of the optimised-orders preview. Mirrors the exact same
|
// Mobile-only rendering of the optimised-orders preview. Mirrors the exact same
|
||||||
// fields, chips and tooltips as the desktop table rows — no behaviour added,
|
// fields, chips and tooltips as the desktop table rows — no behaviour added,
|
||||||
// purely a card layout for phones. Renders for both aiMode 1 and normal mode;
|
// purely a card layout for phones. Renders for both aiMode 1 and normal mode;
|
||||||
// the Zone / Rider fields are gated on aiMode just like the table columns.
|
// the Zone / Rider fields are gated on aiMode just like the table columns.
|
||||||
const MobileOrdersList = ({ list, aiMode }) => {
|
// Reads the same sorted `table` row model the desktop table does, so a
|
||||||
if (!list || list.length === 0) {
|
// sort applied before switching to a narrow viewport isn't lost.
|
||||||
|
const MobileOrdersList = ({ table, aiMode }) => {
|
||||||
|
const rows = table.getRowModel().rows;
|
||||||
|
if (!rows || rows.length === 0) {
|
||||||
return <EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />;
|
return <EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<MobileCardList scroll>
|
<MobileCardList scroll>
|
||||||
{list.map((val, index) => (
|
{rows.map((tableRow, index) => {
|
||||||
<MobileCard
|
const val = tableRow.original;
|
||||||
key={index}
|
return (
|
||||||
header={
|
<MobileCard
|
||||||
<HStack justify="between" vAlign="center" gap={1}>
|
key={tableRow.id}
|
||||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
header={
|
||||||
<Text weight="bold" color="secondary">
|
<HStack justify="between" vAlign="center" gap={1}>
|
||||||
#{index + 1}
|
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||||
</Text>
|
<Text weight="bold" color="secondary">
|
||||||
{aiMode == 1 && <Badge variant="blue" label={val.zone_name} />}
|
#{index + 1}
|
||||||
|
</Text>
|
||||||
|
{aiMode == 1 && <Badge variant="blue" label={val.zone_name} />}
|
||||||
|
</HStack>
|
||||||
|
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
|
||||||
</HStack>
|
</HStack>
|
||||||
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
|
}
|
||||||
</HStack>
|
>
|
||||||
}
|
<MobileFieldGrid>
|
||||||
>
|
<MobileField label="Tenant" full>
|
||||||
<MobileFieldGrid>
|
<Tooltip content={val.tenantaddress}>
|
||||||
<MobileField label="Tenant" full>
|
|
||||||
<Tooltip content={val.tenantaddress}>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text maxLines={1}>{val.tenantname}</Text>
|
|
||||||
<Text type="supporting" maxLines={1}>
|
|
||||||
{val.tenantsuburb}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" maxLines={1}>
|
|
||||||
{val.applocation}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Tooltip>
|
|
||||||
</MobileField>
|
|
||||||
|
|
||||||
<MobileField label="Order Location" full>
|
|
||||||
<Tooltip content={val.locationaddress}>
|
|
||||||
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip content="Order Id">
|
|
||||||
<Text type="supporting" maxLines={1}>
|
|
||||||
{val.orderid}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
<HStack gap={3}>
|
|
||||||
<Tooltip content="Ordered date">
|
|
||||||
<VStack gap={0} padding={0}>
|
<VStack gap={0} padding={0}>
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text>
|
<Text maxLines={1}>{val.tenantname}</Text>
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text>
|
<Text type="supporting" maxLines={1}>
|
||||||
|
{val.tenantsuburb}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" maxLines={1}>
|
||||||
|
{val.applocation}
|
||||||
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
-
|
|
||||||
<Tooltip content="Delivery date">
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
|
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
|
|
||||||
</VStack>
|
|
||||||
</Tooltip>
|
|
||||||
</HStack>
|
|
||||||
</MobileField>
|
|
||||||
|
|
||||||
<MobileField label="Pickup">
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text type="supporting">{val.pickupcustomer}</Text>
|
|
||||||
<Text type="supporting">{val.pickupcontactno}</Text>
|
|
||||||
<Tooltip content={val.pickupaddress}>
|
|
||||||
<Text type="supporting">{val.pickupsuburb || val.pickupaddress.slice(0, 20)}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</VStack>
|
|
||||||
</MobileField>
|
|
||||||
|
|
||||||
<MobileField label="Delivery">
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text type="supporting">{val.deliverycustomer}</Text>
|
|
||||||
<Text type="supporting">{val.deliverycontactno}</Text>
|
|
||||||
<Tooltip content={val.deliveryaddress}>
|
|
||||||
<Text type="supporting">{val.deliverysuburb || val.deliveryaddress.slice(0, 20)}</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</VStack>
|
|
||||||
</MobileField>
|
|
||||||
|
|
||||||
{val.ordernotes ? <MobileField label="Notes" value={val.ordernotes} full /> : null}
|
|
||||||
|
|
||||||
{aiMode == 1 && (
|
|
||||||
<MobileField label="Rider" full>
|
|
||||||
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text>
|
|
||||||
<Text>ID : {val.userid}</Text>
|
|
||||||
</MobileField>
|
</MobileField>
|
||||||
)}
|
|
||||||
|
|
||||||
<MobileField label="Profit">
|
<MobileField label="Order Location" full>
|
||||||
<VStack gap={1} padding={0}>
|
<Tooltip content={val.locationaddress}>
|
||||||
<Tooltip content="Charges">
|
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
|
||||||
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Amount">
|
<Tooltip content="Order Id">
|
||||||
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} />
|
<Text type="supporting" maxLines={1}>
|
||||||
|
{val.orderid}
|
||||||
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</VStack>
|
<HStack gap={3}>
|
||||||
</MobileField>
|
<Tooltip content="Ordered date">
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text>
|
||||||
|
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Tooltip>
|
||||||
|
-
|
||||||
|
<Tooltip content="Delivery date">
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
|
||||||
|
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</MobileField>
|
||||||
|
|
||||||
<MobileField label="KMS">
|
<MobileField label="Pickup">
|
||||||
<VStack gap={1} padding={0}>
|
<VStack gap={0} padding={0}>
|
||||||
<Tooltip content="KMS">
|
<Text type="supporting">{val.pickupcustomer}</Text>
|
||||||
<Badge variant="error" label={`${Number(val.kms || 0).toFixed(2)} km`} />
|
<Text type="supporting">{val.pickupcontactno}</Text>
|
||||||
</Tooltip>
|
<Tooltip content={val.pickupaddress}>
|
||||||
<Tooltip content="Cumulative Kms">
|
<Text type="supporting">{val.pickupsuburb || val.pickupaddress.slice(0, 20)}</Text>
|
||||||
<Badge variant="green" label={`${val.cumulativekms} km`} />
|
</Tooltip>
|
||||||
</Tooltip>
|
</VStack>
|
||||||
</VStack>
|
</MobileField>
|
||||||
</MobileField>
|
|
||||||
</MobileFieldGrid>
|
<MobileField label="Delivery">
|
||||||
</MobileCard>
|
<VStack gap={0} padding={0}>
|
||||||
))}
|
<Text type="supporting">{val.deliverycustomer}</Text>
|
||||||
|
<Text type="supporting">{val.deliverycontactno}</Text>
|
||||||
|
<Tooltip content={val.deliveryaddress}>
|
||||||
|
<Text type="supporting">{val.deliverysuburb || val.deliveryaddress.slice(0, 20)}</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</VStack>
|
||||||
|
</MobileField>
|
||||||
|
|
||||||
|
{val.ordernotes ? <MobileField label="Notes" value={val.ordernotes} full /> : null}
|
||||||
|
|
||||||
|
{aiMode == 1 && (
|
||||||
|
<MobileField label="Rider" full>
|
||||||
|
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text>
|
||||||
|
<Text>ID : {val.userid}</Text>
|
||||||
|
</MobileField>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MobileField label="Profit">
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
<Tooltip content="Charges">
|
||||||
|
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Amount">
|
||||||
|
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} />
|
||||||
|
</Tooltip>
|
||||||
|
</VStack>
|
||||||
|
</MobileField>
|
||||||
|
|
||||||
|
<MobileField label="KMS">
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
<Tooltip content="KMS">
|
||||||
|
<Badge variant="error" label={`${Number(val.kms || 0).toFixed(2)} km`} />
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip content="Cumulative Kms">
|
||||||
|
<Badge variant="green" label={`${val.cumulativekms} km`} />
|
||||||
|
</Tooltip>
|
||||||
|
</VStack>
|
||||||
|
</MobileField>
|
||||||
|
</MobileFieldGrid>
|
||||||
|
</MobileCard>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -167,6 +212,14 @@ const OrdersPreview = () => {
|
|||||||
const [payment, setPayment] = useState(null);
|
const [payment, setPayment] = useState(null);
|
||||||
const [finaldeliveryList, setFinalDeliveryList] = useState([]);
|
const [finaldeliveryList, setFinalDeliveryList] = useState([]);
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
// Sorting/resizing state — mirrors orders.js's/riders.js's slices.
|
||||||
|
// pageSize: Infinity keeps every row on one "page" (no Pagination UI):
|
||||||
|
// this is a pre-commit REVIEW step for a single solver run's batch, and
|
||||||
|
// the operator needs to see every affected order before clicking
|
||||||
|
// Finalise, not just whatever page happens to be showing.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination] = useState({ pageIndex: 0, pageSize: Infinity });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
const deliverylist = location.state?.deliverylist;
|
const deliverylist = location.state?.deliverylist;
|
||||||
const zoneData = location.state?.zoneData;
|
const zoneData = location.state?.zoneData;
|
||||||
const metaData = location.state?.metaData;
|
const metaData = location.state?.metaData;
|
||||||
@@ -266,106 +319,225 @@ const OrdersPreview = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const renderRow = (val, index) => (
|
// The column model — everything a column IS declared once here instead of
|
||||||
<Fragment key={index}>
|
// a hand-written body row, same shape as orders.js's/riders.js's/
|
||||||
<TableRow>
|
// Tenants.js's `columns`. Zone/Rider are pushed in conditionally, matching
|
||||||
<TableCell>
|
// the exact `aiMode == 1` gate the header row used before.
|
||||||
<Text>{index + 1}</Text>
|
const columns = useMemo(() => {
|
||||||
</TableCell>
|
const base = [
|
||||||
{aiMode == 1 && (
|
{
|
||||||
<TableCell>
|
id: 'serial',
|
||||||
<Badge variant="blue" label={val.zone_name} />
|
size: 44,
|
||||||
</TableCell>
|
minSize: 36,
|
||||||
)}
|
maxSize: 52,
|
||||||
<TableCell>
|
enableSorting: false,
|
||||||
<Tooltip content={val.tenantaddress}>
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => <Text>{t.getRowModel().rows.indexOf(row) + 1}</Text>
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (aiMode == 1) {
|
||||||
|
base.push({
|
||||||
|
id: 'zone',
|
||||||
|
accessorKey: 'zone_name',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Zone' },
|
||||||
|
header: () => <SectionLabel>Zone</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Badge variant="blue" label={getValue()} />
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
base.push(
|
||||||
|
{
|
||||||
|
id: 'tenant',
|
||||||
|
accessorKey: 'tenantname',
|
||||||
|
size: 220,
|
||||||
|
minSize: 170,
|
||||||
|
meta: { label: 'Tenant' },
|
||||||
|
header: () => <SectionLabel>Tenant</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<Tooltip content={row.original.tenantaddress}>
|
||||||
<VStack gap={0} padding={0}>
|
<VStack gap={0} padding={0}>
|
||||||
<Text maxLines={1}>{val.tenantname}</Text>
|
<Text maxLines={1}>{getValue()}</Text>
|
||||||
<Text type="supporting" maxLines={1}>
|
<Text type="supporting" maxLines={1}>
|
||||||
{val.tenantsuburb}
|
{row.original.tenantsuburb}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="supporting" maxLines={1}>
|
<Text type="supporting" maxLines={1}>
|
||||||
{val.applocation}
|
{row.original.applocation}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TableCell>
|
)
|
||||||
<TableCell>
|
},
|
||||||
<Tooltip content={val.locationaddress}>
|
{
|
||||||
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
|
id: 'orderLocation',
|
||||||
</Tooltip>
|
accessorKey: 'locationname',
|
||||||
<Tooltip content="Order Id">
|
size: 240,
|
||||||
<Text type="supporting" maxLines={1}>
|
minSize: 190,
|
||||||
{val.orderid}
|
meta: { label: 'Order Location' },
|
||||||
</Text>
|
header: () => <SectionLabel>Order Location</SectionLabel>,
|
||||||
</Tooltip>
|
cell: ({ row }) => {
|
||||||
<HStack gap={3}>
|
const val = row.original;
|
||||||
<Tooltip content="Ordered date">
|
return (
|
||||||
<VStack gap={0} padding={0}>
|
<>
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text>
|
<Tooltip content={val.locationaddress}>
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text>
|
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
|
||||||
</VStack>
|
</Tooltip>
|
||||||
</Tooltip>
|
<Tooltip content="Order Id">
|
||||||
-
|
<Text type="supporting" maxLines={1}>
|
||||||
<Tooltip content="Delivery date">
|
{val.orderid}
|
||||||
<VStack gap={0} padding={0}>
|
</Text>
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
|
</Tooltip>
|
||||||
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
|
<HStack gap={3}>
|
||||||
</VStack>
|
<Tooltip content="Ordered date">
|
||||||
</Tooltip>
|
<VStack gap={0} padding={0}>
|
||||||
</HStack>
|
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text>
|
||||||
</TableCell>
|
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text>
|
||||||
<TableCell>
|
</VStack>
|
||||||
|
</Tooltip>
|
||||||
|
-
|
||||||
|
<Tooltip content="Delivery date">
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
|
||||||
|
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
|
||||||
|
</VStack>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pickup',
|
||||||
|
accessorKey: 'pickupcustomer',
|
||||||
|
size: 200,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Pickup' },
|
||||||
|
header: () => <SectionLabel>Pickup</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
<VStack gap={0} padding={0}>
|
<VStack gap={0} padding={0}>
|
||||||
<Text type="supporting">{val.pickupcustomer}</Text>
|
<Text type="supporting">{getValue()}</Text>
|
||||||
<Text type="supporting">{val.pickupcontactno}</Text>
|
<Text type="supporting">{row.original.pickupcontactno}</Text>
|
||||||
<Tooltip content={val.pickupaddress}>
|
<Tooltip content={row.original.pickupaddress}>
|
||||||
<Text type="supporting">{val.pickupsuburb || val.pickupaddress.slice(0, 20)}</Text>
|
<Text type="supporting">{row.original.pickupsuburb || row.original.pickupaddress.slice(0, 20)}</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TableCell>
|
)
|
||||||
<TableCell>
|
},
|
||||||
|
{
|
||||||
|
id: 'delivery',
|
||||||
|
accessorKey: 'deliverycustomer',
|
||||||
|
size: 200,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Delivery' },
|
||||||
|
header: () => <SectionLabel>Delivery</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
<VStack gap={0} padding={0}>
|
<VStack gap={0} padding={0}>
|
||||||
<Text type="supporting">{val.deliverycustomer}</Text>
|
<Text type="supporting">{getValue()}</Text>
|
||||||
<Text type="supporting">{val.deliverycontactno}</Text>
|
<Text type="supporting">{row.original.deliverycontactno}</Text>
|
||||||
<Tooltip content={val.deliveryaddress}>
|
<Tooltip content={row.original.deliveryaddress}>
|
||||||
<Text type="supporting">{val.deliverysuburb || val.deliveryaddress.slice(0, 20)}</Text>
|
<Text type="supporting">{row.original.deliverysuburb || row.original.deliveryaddress.slice(0, 20)}</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TableCell>
|
)
|
||||||
<TableCell>{val.ordernotes}</TableCell>
|
},
|
||||||
{aiMode == 1 && (
|
{
|
||||||
<TableCell>
|
id: 'notes',
|
||||||
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text>
|
accessorKey: 'ordernotes',
|
||||||
<Text>ID : {val.userid}</Text>
|
size: 160,
|
||||||
</TableCell>
|
minSize: 120,
|
||||||
)}
|
meta: { label: 'Notes' },
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
header: () => <SectionLabel>Notes</SectionLabel>,
|
||||||
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
|
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||||
</TableCell>
|
}
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
);
|
||||||
|
|
||||||
|
if (aiMode == 1) {
|
||||||
|
base.push({
|
||||||
|
id: 'rider',
|
||||||
|
accessorKey: 'username',
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Rider' },
|
||||||
|
header: () => <SectionLabel>Rider</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<>
|
||||||
|
<Text style={{ whiteSpace: 'nowrap' }}>{getValue()}</Text>
|
||||||
|
<Text>ID : {row.original.userid}</Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
base.push(
|
||||||
|
{
|
||||||
|
id: 'type',
|
||||||
|
accessorKey: 'ordertype',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Type', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Type</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Badge variant={orderTypeBadge(getValue())} label={getValue()} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'profit',
|
||||||
|
accessorKey: 'deliveryamt',
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Profit', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Profit</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
<VStack gap={1} padding={0} vAlign="center">
|
<VStack gap={1} padding={0} vAlign="center">
|
||||||
<Tooltip content="Charges">
|
<Tooltip content="Charges">
|
||||||
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
|
<Badge variant="error" label={`₹ ${row.original.deliverycharge.toFixed(2)} `} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Amount">
|
<Tooltip content="Amount">
|
||||||
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} />
|
<Badge variant="green" label={`₹ ${row.original.deliveryamt.toFixed(2)} `} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TableCell>
|
)
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
},
|
||||||
|
{
|
||||||
|
id: 'kms',
|
||||||
|
accessorKey: 'kms',
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Kms', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Kms</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
<VStack gap={1} padding={0} vAlign="center">
|
<VStack gap={1} padding={0} vAlign="center">
|
||||||
<Tooltip content="KMS">
|
<Tooltip content="KMS">
|
||||||
<Badge variant="error" label={`${Number(val.kms || 0).toFixed(2)} km`} />
|
<Badge variant="error" label={`${Number(getValue() || 0).toFixed(2)} km`} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Cumulative Kms">
|
<Tooltip content="Cumulative Kms">
|
||||||
<Badge variant="green" label={`${val.cumulativekms} km`} />
|
<Badge variant="green" label={`${row.original.cumulativekms} km`} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</VStack>
|
</VStack>
|
||||||
</TableCell>
|
)
|
||||||
</TableRow>
|
}
|
||||||
</Fragment>
|
);
|
||||||
);
|
|
||||||
|
return base;
|
||||||
|
}, [aiMode]);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: ORDERS_PREVIEW_TABLE_FEATURES,
|
||||||
|
// finaldeliveryList is already component state (stable reference between
|
||||||
|
// renders unless setFinalDeliveryList is called) — required for
|
||||||
|
// TanStack's row-model memoization to hold correctly.
|
||||||
|
data: finaldeliveryList,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.orderid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -405,34 +577,129 @@ const OrdersPreview = () => {
|
|||||||
|
|
||||||
<DataCard>
|
<DataCard>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<MobileOrdersList list={finaldeliveryList} aiMode={aiMode} />
|
<MobileOrdersList table={table} aiMode={aiMode} />
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={aiMode == 1 ? 1400 : 1200}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Orders preview"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
{aiMode == 1 && <TableHeaderCell>Zone</TableHeaderCell>}
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Tenant</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Order Location</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Pickup</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Delivery</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>Notes</TableHeaderCell>
|
<TableHeaderCell
|
||||||
{aiMode == 1 && <TableHeaderCell>Rider</TableHeaderCell>}
|
key={header.id}
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Type</TableHeaderCell>
|
scope="col"
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Profit</TableHeaderCell>
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>KMS</TableHeaderCell>
|
style={{
|
||||||
</TableRow>
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{finaldeliveryList?.length == 0 && (
|
{finaldeliveryList?.length == 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={10}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />
|
<EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{finaldeliveryList?.map((val, index) => renderRow(val, index))}
|
{table.getRowModel().rows.map((tableRow) => (
|
||||||
|
<Fragment key={tableRow.id}>
|
||||||
|
<TableRow>
|
||||||
|
{tableRow.getAllCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Grid } from '@astryxdesign/core/Grid';
|
|||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -27,13 +28,28 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
|||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import { MdOutlineCloudUpload, MdDeleteOutline } from 'react-icons/md';
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing behave identically across the console. No pagination
|
||||||
|
// feature registered here deliberately — see the comment above dropColumns.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
|
import { MdOutlineCloudUpload, MdDeleteOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||||
import { FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt, FaTruck } from 'react-icons/fa';
|
import { FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt, FaTruck } from 'react-icons/fa';
|
||||||
import { FaLocationDot } from 'react-icons/fa6';
|
import { FaLocationDot } from 'react-icons/fa6';
|
||||||
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
import PageHeader from 'components/nearle_components/PageHeader';
|
import PageHeader from 'components/nearle_components/PageHeader';
|
||||||
import { PageShell } from 'components/nearle_components/PageLayout';
|
import { PageShell } from 'components/nearle_components/PageLayout';
|
||||||
|
import { DT } from 'themes/dt/tokens';
|
||||||
import { TableScroll } from 'themes/dt/primitives';
|
import { TableScroll } from 'themes/dt/primitives';
|
||||||
import './OrdersRedesign.css';
|
import './OrdersRedesign.css';
|
||||||
|
|
||||||
@@ -110,6 +126,100 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — sorting/resizing only,
|
||||||
|
// no pagination (see the comment above dropColumns for why) and no
|
||||||
|
// row-selection/column-visibility (neither table needs them).
|
||||||
|
const MULTI_ORDERS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature
|
||||||
|
});
|
||||||
|
|
||||||
|
// Uppercase micro-label for the tables' column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
// File Preview table's columns — purely read-only display, no closures over
|
||||||
|
// component state, so this is safe to declare once at module scope rather
|
||||||
|
// than inside the component.
|
||||||
|
const previewColumns = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => t.getRowModel().rows.indexOf(row) + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
accessorKey: 'firstname',
|
||||||
|
size: 170,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Name' },
|
||||||
|
header: () => <SectionLabel>Name</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contact',
|
||||||
|
accessorKey: 'contactno',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Contact' },
|
||||||
|
header: () => <SectionLabel>Contact</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
accessorKey: 'address',
|
||||||
|
size: 260,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Address' },
|
||||||
|
header: () => <SectionLabel>Address</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Tooltip content={getValue() || ''}>
|
||||||
|
<Text maxLines={1} style={{ maxWidth: 240 }}>
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qty',
|
||||||
|
accessorKey: 'quantity',
|
||||||
|
size: 90,
|
||||||
|
minSize: 70,
|
||||||
|
meta: { label: 'Qty', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Qty</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() ?? '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cash',
|
||||||
|
accessorKey: 'collectionamt',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Cash', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Cash</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => {
|
||||||
|
const v = getValue();
|
||||||
|
return v != null ? `₹${Number(v).toFixed(2)}` : '—';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
|
const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
|
||||||
<div
|
<div
|
||||||
role="button"
|
role="button"
|
||||||
@@ -213,6 +323,14 @@ const MultipleOrders = () => {
|
|||||||
const [users, setUsers] = useState([]);
|
const [users, setUsers] = useState([]);
|
||||||
const [fileName, setFileName] = useState('');
|
const [fileName, setFileName] = useState('');
|
||||||
|
|
||||||
|
// Table sorting/resizing state — mirrors orders.js's/riders.js's slices.
|
||||||
|
// Two independent table instances (Drop List, File Preview), each with its
|
||||||
|
// own state.
|
||||||
|
const [dropSorting, setDropSorting] = useState([]);
|
||||||
|
const [dropColumnSizing, setDropColumnSizing] = useState({});
|
||||||
|
const [previewSorting, setPreviewSorting] = useState([]);
|
||||||
|
const [previewColumnSizing, setPreviewColumnSizing] = useState({});
|
||||||
|
|
||||||
const toastCacheRef = useRef({});
|
const toastCacheRef = useRef({});
|
||||||
|
|
||||||
// ============================== toast ==============================
|
// ============================== toast ==============================
|
||||||
@@ -749,6 +867,179 @@ const MultipleOrders = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Drop List's column model — deliberately NOT wrapped in useMemo: its
|
||||||
|
// Address/Qty/Cash cells close over uploadType, pickCust and several
|
||||||
|
// setDropCust-based handlers that change often, and every one of them is
|
||||||
|
// already recreated fresh each render in this file (none are themselves
|
||||||
|
// memoized) — memoizing here would need an unmanageable dependency list
|
||||||
|
// for no benefit, matching the same call made on Tenants.js's Actions
|
||||||
|
// column. Sorting/resizing are display-only — this table never paginates,
|
||||||
|
// since the operator has to review and complete EVERY row's address
|
||||||
|
// before Dispatch is enabled (an incomplete row blocks submit with a
|
||||||
|
// toast), and hiding some of those rows behind a page-2 would work
|
||||||
|
// against that. Submission (`createorders`) always reads the full
|
||||||
|
// `dropCust` array regardless of what's sorted/visible.
|
||||||
|
const dropColumns = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 40,
|
||||||
|
minSize: 34,
|
||||||
|
maxSize: 48,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => t.getRowModel().rows.indexOf(row) + 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'customer',
|
||||||
|
accessorKey: 'firstname',
|
||||||
|
size: 160,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Customer' },
|
||||||
|
header: () => <SectionLabel>Customer</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
size: 260,
|
||||||
|
minSize: 200,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: 'Address' },
|
||||||
|
header: () => <SectionLabel>Address</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
return customer.latitude == null || customer.longitude == null ? (
|
||||||
|
<AddressAutocomplete
|
||||||
|
id={`drop-address-${customer._rowkey}`}
|
||||||
|
placeholder="Search address…"
|
||||||
|
value={customer.address || ''}
|
||||||
|
onChange={(text) => setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c)))}
|
||||||
|
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)}
|
||||||
|
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={customer.address || ''}>
|
||||||
|
<Text maxLines={1} style={{ maxWidth: 260 }}>
|
||||||
|
{customer.address}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'qty',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: 'Qty', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Qty</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
return uploadType === 0 ? (
|
||||||
|
customer.quantity
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
label="Quantity"
|
||||||
|
isLabelHidden
|
||||||
|
value={customer.quantity || undefined}
|
||||||
|
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cash',
|
||||||
|
size: 130,
|
||||||
|
minSize: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: 'Cash', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Cash</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
return uploadType === 0 ? (
|
||||||
|
`₹${Number(customer.collectionamt || 0).toFixed(2)}`
|
||||||
|
) : (
|
||||||
|
<NumberInput
|
||||||
|
label="Collection amount"
|
||||||
|
isLabelHidden
|
||||||
|
value={customer.collectionamt || undefined}
|
||||||
|
onChange={(v) => handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'km',
|
||||||
|
accessorKey: 'distance',
|
||||||
|
size: 90,
|
||||||
|
minSize: 70,
|
||||||
|
meta: { label: 'Km' },
|
||||||
|
header: () => <SectionLabel>Km</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'charge',
|
||||||
|
accessorFn: (row) => Number(row.totalcharge) || 0,
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Charge', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Charge</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="bold">₹{getValue().toFixed(2)}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action',
|
||||||
|
size: 56,
|
||||||
|
minSize: 48,
|
||||||
|
maxSize: 64,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '' },
|
||||||
|
header: () => null,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const customer = row.original;
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
label="Remove"
|
||||||
|
tooltip="Remove"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={14} />}
|
||||||
|
onClick={() => handleCheckboxChange1(customer)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const dropTable = useTable({
|
||||||
|
features: MULTI_ORDERS_TABLE_FEATURES,
|
||||||
|
data: dropCust,
|
||||||
|
columns: dropColumns,
|
||||||
|
getRowId: (row, index) => row._rowkey ?? `row-${index}`,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting: dropSorting, columnSizing: dropColumnSizing },
|
||||||
|
onSortingChange: setDropSorting,
|
||||||
|
onColumnSizingChange: setDropColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewTable = useTable({
|
||||||
|
features: MULTI_ORDERS_TABLE_FEATURES,
|
||||||
|
data: users,
|
||||||
|
columns: previewColumns,
|
||||||
|
getRowId: (row, index) => row._rowkey ?? `row-${index}`,
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting: previewSorting, columnSizing: previewColumnSizing },
|
||||||
|
onSortingChange: setPreviewSorting,
|
||||||
|
onColumnSizingChange: setPreviewColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(loading || btnLoading) && <Loader />}
|
{(loading || btnLoading) && <Loader />}
|
||||||
@@ -1043,91 +1334,116 @@ const MultipleOrders = () => {
|
|||||||
|
|
||||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||||
{previewMode === 'drops' && (
|
{previewMode === 'drops' && (
|
||||||
<TableScroll minWidth={720}>
|
<TableScroll minWidth={dropTable.getTotalSize()}>
|
||||||
<Table density="compact" dividers="rows" hasHover>
|
<Table
|
||||||
|
density="compact"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: dropTable.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{dropTable.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Customer</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Address</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Km</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell style={{ textAlign: 'right' }}>Charge</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell></TableHeaderCell>
|
key={header.id}
|
||||||
</TableRow>
|
scope="col"
|
||||||
</TableHeader>
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
<TableBody>
|
style={{
|
||||||
{dropCust.map((customer, index) => (
|
width: header.getSize(),
|
||||||
<TableRow key={customer._rowkey || index}>
|
minWidth: header.column.columnDef.minSize,
|
||||||
<TableCell>{index + 1}</TableCell>
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
<TableCell>
|
textAlign: align,
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
position: 'relative'
|
||||||
{customer.firstname}
|
}}
|
||||||
</Text>
|
>
|
||||||
</TableCell>
|
<HStack
|
||||||
<TableCell style={{ maxWidth: 260 }}>
|
gap={1}
|
||||||
{customer.latitude == null || customer.longitude == null ? (
|
vAlign="center"
|
||||||
<AddressAutocomplete
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
id={`drop-address-${customer._rowkey}`}
|
role={canSort ? 'button' : undefined}
|
||||||
placeholder="Search address…"
|
tabIndex={canSort ? 0 : undefined}
|
||||||
value={customer.address || ''}
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
onChange={(text) =>
|
onKeyDown={
|
||||||
setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c)))
|
canSort
|
||||||
}
|
? (e) => {
|
||||||
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)}
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined}
|
e.preventDefault();
|
||||||
/>
|
header.column.toggleSorting();
|
||||||
) : (
|
}
|
||||||
<Tooltip content={customer.address || ''}>
|
}
|
||||||
<Text maxLines={1} style={{ maxWidth: 260 }}>
|
: undefined
|
||||||
{customer.address}
|
}
|
||||||
</Text>
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
</Tooltip>
|
>
|
||||||
)}
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</TableCell>
|
{canSort &&
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
(sorted === 'asc' ? (
|
||||||
{uploadType === 0 ? (
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
customer.quantity
|
) : sorted === 'desc' ? (
|
||||||
) : (
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
<NumberInput
|
) : (
|
||||||
label="Quantity"
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
isLabelHidden
|
))}
|
||||||
value={customer.quantity || undefined}
|
</HStack>
|
||||||
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
|
|
||||||
size="sm"
|
{header.column.getCanResize() && (
|
||||||
/>
|
<Center
|
||||||
)}
|
aria-hidden="true"
|
||||||
</TableCell>
|
onMouseDown={header.getResizeHandler()}
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
onTouchStart={header.getResizeHandler()}
|
||||||
{uploadType === 0 ? (
|
style={{
|
||||||
`₹${Number(customer.collectionamt || 0).toFixed(2)}`
|
position: 'absolute',
|
||||||
) : (
|
top: 0,
|
||||||
<NumberInput
|
right: 0,
|
||||||
label="Collection amount"
|
height: '100%',
|
||||||
isLabelHidden
|
width: 10,
|
||||||
value={customer.collectionamt || undefined}
|
cursor: 'col-resize',
|
||||||
onChange={(v) => handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)}
|
userSelect: 'none',
|
||||||
size="sm"
|
touchAction: 'none'
|
||||||
/>
|
}}
|
||||||
)}
|
>
|
||||||
</TableCell>
|
<Center
|
||||||
<TableCell>{customer.distance || '—'}</TableCell>
|
style={{
|
||||||
<TableCell style={{ textAlign: 'right' }}>
|
width: 2,
|
||||||
<Text weight="bold">₹{Number(customer?.totalcharge || 0).toFixed(2)}</Text>
|
height: '60%',
|
||||||
</TableCell>
|
borderRadius: 'var(--radius-element)',
|
||||||
<TableCell>
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
<IconButton
|
transition: 'background-color 120ms ease'
|
||||||
label="Remove"
|
}}
|
||||||
tooltip="Remove"
|
/>
|
||||||
size="sm"
|
</Center>
|
||||||
variant="destructive"
|
)}
|
||||||
icon={<MdDeleteOutline size={14} />}
|
</TableHeaderCell>
|
||||||
onClick={() => handleCheckboxChange1(customer)}
|
);
|
||||||
/>
|
})}
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{dropTable.getRowModel().rows.map((tableRow) => (
|
||||||
|
<TableRow key={tableRow.id}>
|
||||||
|
{tableRow.getAllCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
style={{
|
||||||
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{/* Summary row, not a data row — deliberately hand-coded
|
||||||
|
rather than run through the column model, and
|
||||||
|
always sums the FULL dropCust array (not affected
|
||||||
|
by sorting). */}
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Text weight="bold">Total</Text>
|
<Text weight="bold">Total</Text>
|
||||||
@@ -1155,39 +1471,110 @@ const MultipleOrders = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{previewMode === 'preview' && (
|
{previewMode === 'preview' && (
|
||||||
<TableScroll minWidth={640}>
|
<TableScroll minWidth={previewTable.getTotalSize()}>
|
||||||
<Table density="compact" dividers="rows" hasHover>
|
<Table
|
||||||
|
density="compact"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: previewTable.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{previewTable.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Name</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Contact</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Address</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
|
return (
|
||||||
</TableRow>
|
<TableHeaderCell
|
||||||
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{users.map((u, i) => (
|
{previewTable.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={i}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{i + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>
|
<TableCell
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
key={cell.id}
|
||||||
{u.firstname || '—'}
|
style={{
|
||||||
</Text>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
</TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell>{u.contactno || '—'}</TableCell>
|
}}
|
||||||
<TableCell style={{ maxWidth: 240 }}>
|
>
|
||||||
<Tooltip content={u.address || ''}>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<Text maxLines={1} style={{ maxWidth: 240 }}>
|
</TableCell>
|
||||||
{u.address || '—'}
|
))}
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>{u.quantity ?? '—'}</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
{u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
|
|||||||
import StatusTabs from 'components/nearle_components/StatusTabs';
|
import StatusTabs from 'components/nearle_components/StatusTabs';
|
||||||
import TableLoader from 'components/nearle_components/TableLoader';
|
import TableLoader from 'components/nearle_components/TableLoader';
|
||||||
import PageHeader from 'components/nearle_components/PageHeader';
|
import PageHeader from 'components/nearle_components/PageHeader';
|
||||||
|
import StatCard from 'components/nearle_components/StatCard';
|
||||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
||||||
import { PageShell, DataCard } from 'components/nearle_components/PageLayout';
|
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
|
||||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
|
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
|
||||||
@@ -909,6 +910,25 @@ const Orders = () => {
|
|||||||
<PageShell fill>
|
<PageShell fill>
|
||||||
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live />
|
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live />
|
||||||
|
|
||||||
|
{/* Derived from the SAME statusCounts map the tab badges below read,
|
||||||
|
by mapping over ORDERS_STATUS_TABS rather than a second parallel
|
||||||
|
grouping — so this row can never disagree with the tabs. */}
|
||||||
|
<KpiGrid>
|
||||||
|
{ORDERS_STATUS_TABS.map((t) => {
|
||||||
|
const Icon = t.icon;
|
||||||
|
return (
|
||||||
|
<StatCard
|
||||||
|
key={t.status}
|
||||||
|
title={t.label}
|
||||||
|
value={t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)}
|
||||||
|
icon={<Icon size={20} />}
|
||||||
|
color={t.color}
|
||||||
|
loading={isLoadingGetOrders}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</KpiGrid>
|
||||||
|
|
||||||
<DataCard
|
<DataCard
|
||||||
fill
|
fill
|
||||||
tabs={
|
tabs={
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -15,15 +15,34 @@ import {
|
|||||||
MdOutlineLocalShipping,
|
MdOutlineLocalShipping,
|
||||||
MdOutlineCheckCircle,
|
MdOutlineCheckCircle,
|
||||||
MdOutlinePendingActions,
|
MdOutlinePendingActions,
|
||||||
MdOutlineCancel
|
MdOutlineCancel,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
import { FaCircleCheck } from 'react-icons/fa6';
|
import { FaCircleCheck } from 'react-icons/fa6';
|
||||||
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -34,6 +53,7 @@ import { Selector } from '@astryxdesign/core/Selector';
|
|||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import MapWithRoute from './mapWithRoute';
|
import MapWithRoute from './mapWithRoute';
|
||||||
@@ -119,14 +139,43 @@ const StampCell = ({ value, formatDate, formatTime, success }) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const ORDERS_DETAILS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
// ==============================|| Orders Details ||============================== //
|
// ==============================|| Orders Details ||============================== //
|
||||||
|
|
||||||
export default function OrdersDetails() {
|
export default function OrdersDetails() {
|
||||||
const isMobile = useIsNarrowViewport();
|
const isMobile = useIsNarrowViewport();
|
||||||
const loadMoreRef = useRef();
|
|
||||||
const userid = localStorage.getItem('userid');
|
const userid = localStorage.getItem('userid');
|
||||||
const [page, setPage] = useState(0);
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(50);
|
// slices. Replaces the old page/rowsPerPage pair, which was never actually
|
||||||
|
// wired to real pagination (the query key hardcodes 200 rows/page server-
|
||||||
|
// side and auto-drains every page unconditionally — see the fetchDeliveries
|
||||||
|
// useInfiniteQuery below); page/rowsPerPage only fed the "#" serial number,
|
||||||
|
// always at page 0.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
const [locaName, setLocoName] = useState('All');
|
const [locaName, setLocoName] = useState('All');
|
||||||
const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||||
const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD'));
|
const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD'));
|
||||||
@@ -297,36 +346,13 @@ export default function OrdersDetails() {
|
|||||||
const deliveredLenght = statusCountByKey.delivered;
|
const deliveredLenght = statusCountByKey.delivered;
|
||||||
const cancelLenght = statusCountByKey.cancelled;
|
const cancelLenght = statusCountByKey.cancelled;
|
||||||
|
|
||||||
useEffect(() => {
|
// The scroll-to-reveal sentinel + IntersectionObserver that used to live
|
||||||
if (!hasNextPage) return;
|
// here was already dead: the unconditional drain effect above (`if
|
||||||
const observer = new IntersectionObserver(
|
// (hasNextPage && !isFetchingNextPage) fetchNextPage()`) exhausts every
|
||||||
(entries) => {
|
// page as soon as data arrives, regardless of scroll position, so
|
||||||
if (entries[0].isIntersecting) {
|
// `hasNextPage` is almost always already false by the time a user could
|
||||||
fetchNextPage();
|
// scroll to the sentinel. Real client-side pagination (via the `table`
|
||||||
}
|
// instance below) replaces it instead.
|
||||||
},
|
|
||||||
{
|
|
||||||
// The sentinel lives AFTER </TableScroll>, not inside it. An
|
|
||||||
// IntersectionObserver only reports on targets that are descendants of
|
|
||||||
// its root, so rooting this at `.dt-table-scroll` meant the callback
|
|
||||||
// never fired on desktop and the report stopped at page 1. (It happened
|
|
||||||
// to work on mobile, where MobileCardList renders instead and the
|
|
||||||
// querySelector returned null — falling back to the viewport.)
|
|
||||||
//
|
|
||||||
// The page scrolls inside AppShell's own container, not the window,
|
|
||||||
// because MainLayout mounts AppShell with height="fill" — so
|
|
||||||
// `.astryx-layout-content` is the real scroll root. Same target
|
|
||||||
// orders.js uses; see the comment in layout/MainLayout/index.js.
|
|
||||||
root: document.querySelector('.astryx-layout-content'),
|
|
||||||
rootMargin: '200px',
|
|
||||||
threshold: 0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
|
|
||||||
return () => {
|
|
||||||
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
|
|
||||||
};
|
|
||||||
}, [hasNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
// ==============================|| Tenant / Location / Rider lookups ||============================== //
|
// ==============================|| Tenant / Location / Rider lookups ||============================== //
|
||||||
|
|
||||||
@@ -457,6 +483,349 @@ export default function OrdersDetails() {
|
|||||||
{ key: 'cancelled', label: 'Cancelled', color: STATUS.cancelled, icon: MdOutlineCancel, value: cancelLenght }
|
{ key: 'cancelled', label: 'Cancelled', color: STATUS.cancelled, icon: MdOutlineCancel, value: cancelLenght }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// A tab/zone/tenant/location/rider/date/search change re-scopes the list,
|
||||||
|
// so page 1 is the only page that still means anything — same reset
|
||||||
|
// orders.js/riders.js do on their own tables.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [currentStatus, appId, tenantid, locationid, selectedRider, startdate, enddate, debouncedSearch]);
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as
|
||||||
|
// orders.js's/riders.js's/Tenants.js's `columns`. Cell JSX below is a
|
||||||
|
// straight port of what each column already rendered — only the wiring
|
||||||
|
// changed (columns own their cell now, instead of a shared body-row map).
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'map',
|
||||||
|
size: 60,
|
||||||
|
minSize: 50,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Map' },
|
||||||
|
header: () => <SectionLabel>Map</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const isDelivered = r.orderstatus === 'delivered';
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
label={isDelivered ? 'View rider route' : 'Available for delivered orders'}
|
||||||
|
tooltip={isDelivered ? 'View rider route' : 'Available for delivered orders'}
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdMap size={14} />}
|
||||||
|
isDisabled={!isDelivered}
|
||||||
|
onClick={() => {
|
||||||
|
if (isDelivered) {
|
||||||
|
getdeliverylogs(r.deliveryid);
|
||||||
|
setMapTenant(r);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'client',
|
||||||
|
accessorKey: 'tenantname',
|
||||||
|
size: 200,
|
||||||
|
minSize: 170,
|
||||||
|
meta: { label: 'Client' },
|
||||||
|
header: () => <SectionLabel>Client</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={1.5} vAlign="start">
|
||||||
|
<AccentAvatar color={BRAND} size={32}>
|
||||||
|
<MdGroups size={16} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0} minWidth={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
<Tooltip content="Order ID">
|
||||||
|
<Text type="supporting" color="secondary" weight="bold">
|
||||||
|
#{row.original.orderid}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{parseDoormileTimestamp(row.original.deliverydate).format('DD/MM/YYYY · hh:mm A')}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pickup',
|
||||||
|
accessorKey: 'pickupcustomer',
|
||||||
|
size: 220,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Pickup' },
|
||||||
|
header: () => <SectionLabel>Pickup</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{r.pickupcontactno}
|
||||||
|
</Text>
|
||||||
|
<Tooltip content={r.Pickupaddress || r.pickupsuburb || ''}>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
|
||||||
|
{r.pickupsuburb || (r.Pickupaddress ? r.Pickupaddress.slice(0, 22) + '…' : '')}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
{r.applocation && <Badge variant="green" icon={<MdPlace size={11} />} label={r.applocation} />}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'drop',
|
||||||
|
accessorKey: 'deliverycustomer',
|
||||||
|
size: 220,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Drop' },
|
||||||
|
header: () => <SectionLabel>Drop</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{r.deliverycontactno}
|
||||||
|
</Text>
|
||||||
|
<Tooltip content={r.deliveryaddress || ''}>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
|
||||||
|
{r.deliverysuburb || (r.deliveryaddress ? r.deliveryaddress.slice(0, 22) + '…' : '')}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'statusRider',
|
||||||
|
accessorKey: 'orderstatus',
|
||||||
|
size: 160,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Status / Rider' },
|
||||||
|
header: () => <SectionLabel>Status / Rider</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return (
|
||||||
|
<VStack gap={1} padding={0}>
|
||||||
|
<StatusBadge status={r.orderstatus} />
|
||||||
|
{r.ridername && (
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<MdDirectionsBike size={12} color={DT.textMuted} />
|
||||||
|
<Tooltip content={`Rider ID: ${r.userid}`}>
|
||||||
|
<Text weight="bold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{r.ridername}
|
||||||
|
</Text>
|
||||||
|
</Tooltip>
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assigned',
|
||||||
|
accessorKey: 'assigntime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Assigned' },
|
||||||
|
header: () => <SectionLabel>Assigned</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accepted',
|
||||||
|
accessorKey: 'acceptedtime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Accepted' },
|
||||||
|
header: () => <SectionLabel>Accepted</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arrived',
|
||||||
|
accessorKey: 'arrivaltime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Arrived' },
|
||||||
|
header: () => <SectionLabel>Arrived</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'picked',
|
||||||
|
accessorKey: 'pickuptime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Picked' },
|
||||||
|
header: () => <SectionLabel>Picked</SectionLabel>,
|
||||||
|
// GET /admin/bookings has no per-event timestamps (accepted/arrived/
|
||||||
|
// pickup/start/cancel) — only assigntime (updatedat) and deliverydate
|
||||||
|
// exist on the mapped row (see fetchDeliveries in api.js), so
|
||||||
|
// row.pickuptime is always undefined and this cell always renders the
|
||||||
|
// muted dash. The old `success` check compared against a
|
||||||
|
// `deliverystatus` field that never existed on this row either (the
|
||||||
|
// real field is `orderstatus`) — dropped rather than guessed, since
|
||||||
|
// orderstatus's confirmed values have no distinct "picked" state.
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'active',
|
||||||
|
accessorKey: 'starttime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Active' },
|
||||||
|
header: () => <SectionLabel>Active</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delivered',
|
||||||
|
accessorKey: 'deliverytime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Delivered' },
|
||||||
|
header: () => <SectionLabel>Delivered</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cancelled',
|
||||||
|
accessorKey: 'canceltime',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Cancelled' },
|
||||||
|
header: () => <SectionLabel>Cancelled</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notes',
|
||||||
|
size: 180,
|
||||||
|
minSize: 140,
|
||||||
|
maxSize: 220,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: 'Notes' },
|
||||||
|
header: () => <SectionLabel>Notes</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
return r.ordernotes ? (
|
||||||
|
<HStack gap={1} vAlign="start">
|
||||||
|
<MdNoteAlt size={12} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
|
||||||
|
<Text type="supporting" color="secondary" maxLines={2}>
|
||||||
|
{r.ordernotes}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
) : (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
—
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kms',
|
||||||
|
accessorKey: 'kms',
|
||||||
|
size: 150,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Kms', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Kms</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const cancelled = r.orderstatus === 'cancelled';
|
||||||
|
return (
|
||||||
|
<VStack gap={1} padding={0} vAlign="center">
|
||||||
|
<MetricPill
|
||||||
|
variant="red"
|
||||||
|
icon={<MdStraighten size={11} />}
|
||||||
|
label={cancelled || r.kms == '' ? '0.00 km' : `${formatKm(r.kms)} km`}
|
||||||
|
tooltip="KMS"
|
||||||
|
/>
|
||||||
|
<MetricPill
|
||||||
|
variant="green"
|
||||||
|
icon={<MdStraighten size={11} />}
|
||||||
|
label={`${formatKm(r.cumulativekms)} km`}
|
||||||
|
tooltip="Actual KMS"
|
||||||
|
/>
|
||||||
|
<MetricPill
|
||||||
|
variant="blue"
|
||||||
|
icon={<MdStraighten size={11} />}
|
||||||
|
label={`${formatKm(r.previouskms || (cancelled ? 0 : r.kms))} km`}
|
||||||
|
tooltip="Rider KMS"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'charges',
|
||||||
|
accessorKey: 'deliverycharges',
|
||||||
|
size: 150,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Charges', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Charges</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const cancelled = r.orderstatus === 'cancelled';
|
||||||
|
return (
|
||||||
|
<VStack gap={1} padding={0} vAlign="center">
|
||||||
|
<MetricPill
|
||||||
|
variant="red"
|
||||||
|
icon={<MdCurrencyRupee size={11} />}
|
||||||
|
label={cancelled || r.deliverycharges == '' ? `0.00` : `${r.deliverycharges}.00`}
|
||||||
|
tooltip="Delivery Charge"
|
||||||
|
/>
|
||||||
|
<MetricPill
|
||||||
|
variant="green"
|
||||||
|
icon={<MdCurrencyRupee size={11} />}
|
||||||
|
label={r.deliveryamt == '' ? `0.00` : `${r.deliveryamt}.00`}
|
||||||
|
tooltip="Delivery Amount"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[pagination.pageIndex, pagination.pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: ORDERS_DETAILS_TABLE_FEATURES,
|
||||||
|
// rows is already useMemo'd above (stable reference) — required for
|
||||||
|
// pagination clicks to stick, see riders.js's fix for the same bug.
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.deliveryid ?? `${row.orderid}-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(fetchDeliveriesIsLoading ||
|
{(fetchDeliveriesIsLoading ||
|
||||||
@@ -470,7 +839,7 @@ export default function OrdersDetails() {
|
|||||||
Export. They used to be split across the header and a separate filter
|
Export. They used to be split across the header and a separate filter
|
||||||
box below, which is why Export kept wrapping onto its own line and
|
box below, which is why Export kept wrapping onto its own line and
|
||||||
left that box half empty. */}
|
left that box half empty. */}
|
||||||
<PageShell>
|
<PageShell fill>
|
||||||
{/* Only the primary action lives in the header. The zone picker and
|
{/* Only the primary action lives in the header. The zone picker and
|
||||||
date range were up here too and the zone picker OVERLAPPED the date
|
date range were up here too and the zone picker OVERLAPPED the date
|
||||||
button — LocationAutocomplete brings its own width and ignores the
|
button — LocationAutocomplete brings its own width and ignores the
|
||||||
@@ -481,17 +850,7 @@ export default function OrdersDetails() {
|
|||||||
title="Orders Details"
|
title="Orders Details"
|
||||||
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
|
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
|
||||||
live
|
live
|
||||||
action={
|
action={<Button label="Export" variant="primary" icon={<MdFileDownload size={16} />} onClick={() => setReportDialog(true)} />}
|
||||||
<Button
|
|
||||||
label="Export"
|
|
||||||
variant="primary"
|
|
||||||
icon={<MdFileDownload size={16} />}
|
|
||||||
onClick={() => {
|
|
||||||
setReportDialog(true);
|
|
||||||
setTimeout(() => setRowsPerPage(''), 0);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<KpiGrid>
|
<KpiGrid>
|
||||||
@@ -512,13 +871,14 @@ export default function OrdersDetails() {
|
|||||||
|
|
||||||
{/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */}
|
{/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */}
|
||||||
<DataCard
|
<DataCard
|
||||||
|
fill
|
||||||
toolbar={
|
toolbar={
|
||||||
<FilterToolbar max={5} minWidth={200}>
|
<FilterToolbar max={5} minWidth={200}>
|
||||||
<LocationAutocomplete
|
<LocationAutocomplete
|
||||||
locaName={locaName}
|
locaName={locaName}
|
||||||
setAppId={setAppId}
|
setAppId={setAppId}
|
||||||
setLocoName={setLocoName}
|
setLocoName={setLocoName}
|
||||||
setPage={setPage}
|
setPage={() => setPagination((p) => ({ ...p, pageIndex: 0 }))}
|
||||||
placeholder="Select Zone"
|
placeholder="Select Zone"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -639,19 +999,23 @@ export default function OrdersDetails() {
|
|||||||
icon={<MdAssignment size={28} />}
|
icon={<MdAssignment size={28} />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
rows?.map((row, index) => {
|
// Same paginated + sorted row model the desktop table
|
||||||
|
// reads, so mobile and desktop never disagree on which
|
||||||
|
// page is showing for the same filters.
|
||||||
|
table.getRowModel().rows.map((tableRow, index) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const rowStatusMeta = getStatusMeta(row.orderstatus);
|
const rowStatusMeta = getStatusMeta(row.orderstatus);
|
||||||
const isDelivered = row.orderstatus === 'delivered';
|
const isDelivered = row.orderstatus === 'delivered';
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={row.deliveryid || `${row.orderid}-${index}`}
|
key={tableRow.id}
|
||||||
accent={rowStatusMeta.color}
|
accent={rowStatusMeta.color}
|
||||||
header={
|
header={
|
||||||
<VStack gap={1.5} padding={0}>
|
<VStack gap={1.5} padding={0}>
|
||||||
<HStack justify="between" vAlign="start" gap={1}>
|
<HStack justify="between" vAlign="start" gap={1}>
|
||||||
<HStack gap={1.5} vAlign="center" minWidth={0}>
|
<HStack gap={1.5} vAlign="center" minWidth={0}>
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
<Text type="supporting" weight="bold" color="secondary">
|
||||||
{String(page * rowsPerPage + index + 1).padStart(2, '0')}
|
{String(pagination.pageIndex * pagination.pageSize + index + 1).padStart(2, '0')}
|
||||||
</Text>
|
</Text>
|
||||||
<AccentAvatar color={BRAND} size={32}>
|
<AccentAvatar color={BRAND} size={32}>
|
||||||
<MdGroups size={16} />
|
<MdGroups size={16} />
|
||||||
@@ -802,248 +1166,168 @@ export default function OrdersDetails() {
|
|||||||
)}
|
)}
|
||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={1600}>
|
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
<TableScroll minWidth={table.getTotalSize()} maxHeight="100%">
|
||||||
<TableHeader>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
<TableRow>
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
Tenants.js's tables. Header and body are both rendered
|
||||||
<TableHeaderCell>Map</TableHeaderCell>
|
FROM the column model, so they cannot drift apart. */}
|
||||||
<TableHeaderCell>Client</TableHeaderCell>
|
<Table
|
||||||
<TableHeaderCell>Pickup</TableHeaderCell>
|
density="balanced"
|
||||||
<TableHeaderCell>Drop</TableHeaderCell>
|
dividers="rows"
|
||||||
<TableHeaderCell>Status / Rider</TableHeaderCell>
|
hasHover
|
||||||
<TableHeaderCell>Assigned</TableHeaderCell>
|
verticalAlign="middle"
|
||||||
<TableHeaderCell>Accepted</TableHeaderCell>
|
aria-label="Orders Details"
|
||||||
<TableHeaderCell>Arrived</TableHeaderCell>
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
<TableHeaderCell>Picked</TableHeaderCell>
|
>
|
||||||
<TableHeaderCell>Active</TableHeaderCell>
|
<TableHeader>
|
||||||
<TableHeaderCell>Delivered</TableHeaderCell>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell>Cancelled</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Notes</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>KMS</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Charges</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
</TableRow>
|
const sorted = header.column.getIsSorted();
|
||||||
</TableHeader>
|
return (
|
||||||
|
<TableHeaderCell
|
||||||
<TableBody>
|
key={header.id}
|
||||||
{fetchDeliveriesIsLoading ? (
|
scope="col"
|
||||||
<TableLoader rows={16} columns={16} />
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
) : rows?.length == 0 ? (
|
style={{
|
||||||
<TableRow>
|
width: header.getSize(),
|
||||||
<TableCell colSpan={16}>
|
minWidth: header.column.columnDef.minSize,
|
||||||
<EmptyState
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
title="No orders to show"
|
textAlign: align,
|
||||||
description={searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'}
|
position: 'relative'
|
||||||
icon={<MdAssignment size={28} />}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
rows?.map((row, index) => {
|
|
||||||
const isDelivered = row.orderstatus === 'delivered';
|
|
||||||
const cancelled = row.orderstatus === 'cancelled';
|
|
||||||
return (
|
|
||||||
<TableRow key={row.deliveryid || `${row.orderid}-${index}`}>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
|
||||||
{String(page * rowsPerPage + index + 1).padStart(2, '0')}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<IconButton
|
|
||||||
label={isDelivered ? 'View rider route' : 'Available for delivered orders'}
|
|
||||||
tooltip={isDelivered ? 'View rider route' : 'Available for delivered orders'}
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdMap size={14} />}
|
|
||||||
isDisabled={!isDelivered}
|
|
||||||
onClick={() => {
|
|
||||||
if (isDelivered) {
|
|
||||||
getdeliverylogs(row.deliveryid);
|
|
||||||
setMapTenant(row);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
</TableCell>
|
<HStack
|
||||||
|
gap={1}
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
vAlign="center"
|
||||||
<HStack gap={1.5} vAlign="start">
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
<AccentAvatar color={BRAND} size={32}>
|
role={canSort ? 'button' : undefined}
|
||||||
<MdGroups size={16} />
|
tabIndex={canSort ? 0 : undefined}
|
||||||
</AccentAvatar>
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
<VStack gap={0} padding={0} minWidth={0}>
|
onKeyDown={
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
canSort
|
||||||
{row.tenantname}
|
? (e) => {
|
||||||
</Text>
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
<Tooltip content="Order ID">
|
e.preventDefault();
|
||||||
<Text type="supporting" color="secondary" weight="bold">
|
header.column.toggleSorting();
|
||||||
#{row.orderid}
|
}
|
||||||
</Text>
|
}
|
||||||
</Tooltip>
|
: undefined
|
||||||
<Text type="supporting" color="secondary">
|
}
|
||||||
{parseDoormileTimestamp(row.deliverydate).format('DD/MM/YYYY · hh:mm A')}
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
</Text>
|
>
|
||||||
</VStack>
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
</HStack>
|
{canSort &&
|
||||||
</TableCell>
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
) : sorted === 'desc' ? (
|
||||||
<VStack gap={0} padding={0}>
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
) : (
|
||||||
{row.pickupcustomer || '—'}
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
</Text>
|
))}
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.pickupcontactno}
|
|
||||||
</Text>
|
|
||||||
<Tooltip content={row.Pickupaddress || row.pickupsuburb || ''}>
|
|
||||||
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
|
|
||||||
{row.pickupsuburb || (row.Pickupaddress ? row.Pickupaddress.slice(0, 22) + '…' : '')}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
{row.applocation && <Badge variant="green" icon={<MdPlace size={11} />} label={row.applocation} />}
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{row.deliverycustomer || '—'}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.deliverycontactno}
|
|
||||||
</Text>
|
|
||||||
<Tooltip content={row.deliveryaddress || ''}>
|
|
||||||
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
|
|
||||||
{row.deliverysuburb || (row.deliveryaddress ? row.deliveryaddress.slice(0, 22) + '…' : '')}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<VStack gap={1} padding={0}>
|
|
||||||
<StatusBadge status={row.orderstatus} />
|
|
||||||
{row.ridername && (
|
|
||||||
<HStack gap={1} vAlign="center">
|
|
||||||
<MdDirectionsBike size={12} color={DT.textMuted} />
|
|
||||||
<Tooltip content={`Rider ID: ${row.userid}`}>
|
|
||||||
<Text weight="bold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{row.ridername}
|
|
||||||
</Text>
|
|
||||||
</Tooltip>
|
|
||||||
</HStack>
|
|
||||||
)}
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<StampCell value={row.assigntime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<StampCell value={row.acceptedtime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<StampCell value={row.arrivaltime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
{/* GET /admin/bookings has no per-event timestamps (accepted/
|
|
||||||
arrived/pickup/start/cancel) — only assigntime (updatedat) and
|
|
||||||
deliverydate exist on the mapped row (see fetchDeliveries in
|
|
||||||
api.js), so row.pickuptime is always undefined and this cell
|
|
||||||
always renders the muted dash. The old `success` check compared
|
|
||||||
against a `deliverystatus` field that never existed on this row
|
|
||||||
either (the real field is `orderstatus`) — dropped rather than
|
|
||||||
guessed, since orderstatus's confirmed values
|
|
||||||
(pending/accepted/active/delivered/cancelled) have no distinct
|
|
||||||
"picked" state to map it to. */}
|
|
||||||
<StampCell value={row.pickuptime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<StampCell value={row.starttime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<StampCell value={row.deliverytime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ verticalAlign: 'top' }}>
|
|
||||||
<StampCell value={row.canceltime} formatDate={formatDate} formatTime={formatTime} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top', maxWidth: 180 }}>
|
|
||||||
{row.ordernotes ? (
|
|
||||||
<HStack gap={1} vAlign="start">
|
|
||||||
<MdNoteAlt size={12} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
|
|
||||||
<Text type="supporting" color="secondary" maxLines={2}>
|
|
||||||
{row.ordernotes}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
</HStack>
|
||||||
) : (
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
—
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top', textAlign: 'center' }}>
|
{header.column.getCanResize() && (
|
||||||
<VStack gap={1} padding={0} vAlign="center">
|
<Center
|
||||||
<MetricPill
|
aria-hidden="true"
|
||||||
variant="red"
|
onMouseDown={header.getResizeHandler()}
|
||||||
icon={<MdStraighten size={11} />}
|
onTouchStart={header.getResizeHandler()}
|
||||||
label={cancelled || row.kms == '' ? '0.00 km' : `${formatKm(row.kms)} km`}
|
style={{
|
||||||
tooltip="KMS"
|
position: 'absolute',
|
||||||
/>
|
top: 0,
|
||||||
<MetricPill
|
right: 0,
|
||||||
variant="green"
|
height: '100%',
|
||||||
icon={<MdStraighten size={11} />}
|
width: 10,
|
||||||
label={`${formatKm(row.cumulativekms)} km`}
|
cursor: 'col-resize',
|
||||||
tooltip="Actual KMS"
|
userSelect: 'none',
|
||||||
/>
|
touchAction: 'none'
|
||||||
<MetricPill
|
}}
|
||||||
variant="blue"
|
>
|
||||||
icon={<MdStraighten size={11} />}
|
<Center
|
||||||
label={`${formatKm(row.previouskms || (cancelled ? 0 : row.kms))} km`}
|
style={{
|
||||||
tooltip="Rider KMS"
|
width: 2,
|
||||||
/>
|
height: '60%',
|
||||||
</VStack>
|
borderRadius: 'var(--radius-element)',
|
||||||
</TableCell>
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
<TableCell style={{ verticalAlign: 'top', textAlign: 'center' }}>
|
<TableBody>
|
||||||
<VStack gap={1} padding={0} vAlign="center">
|
{fetchDeliveriesIsLoading ? (
|
||||||
<MetricPill
|
<TableLoader rows={16} columns={colCount} />
|
||||||
variant="red"
|
) : rows?.length == 0 ? (
|
||||||
icon={<MdCurrencyRupee size={11} />}
|
<TableRow>
|
||||||
label={cancelled || row.deliverycharges == '' ? `0.00` : `${row.deliverycharges}.00`}
|
<TableCell colSpan={colCount}>
|
||||||
tooltip="Delivery Charge"
|
<EmptyState
|
||||||
/>
|
title="No orders to show"
|
||||||
<MetricPill
|
description={searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'}
|
||||||
variant="green"
|
icon={<MdAssignment size={28} />}
|
||||||
icon={<MdCurrencyRupee size={11} />}
|
/>
|
||||||
label={row.deliveryamt == '' ? `0.00` : `${row.deliveryamt}.00`}
|
</TableCell>
|
||||||
tooltip="Delivery Amount"
|
</TableRow>
|
||||||
/>
|
) : (
|
||||||
</VStack>
|
table.getRowModel().rows.map((tableRow) => {
|
||||||
</TableCell>
|
return (
|
||||||
</TableRow>
|
<TableRow key={tableRow.id}>
|
||||||
);
|
{tableRow.getAllCells().map((cell) => (
|
||||||
})
|
<TableCell
|
||||||
)}
|
key={cell.id}
|
||||||
</TableBody>
|
style={{
|
||||||
</Table>
|
verticalAlign: 'top',
|
||||||
</TableScroll>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableScroll>
|
||||||
|
</VStack>
|
||||||
)}
|
)}
|
||||||
|
{/* Pagination replaces the old scroll-to-reveal sentinel — the
|
||||||
{rows?.length !== 0 && (
|
unconditional drain effect above already pulls every matching
|
||||||
|
order into `allRows` regardless of scroll position, so this
|
||||||
|
was always paginating an in-memory array, just without any UI
|
||||||
|
for it. */}
|
||||||
|
{rows?.length > 0 && (
|
||||||
<>
|
<>
|
||||||
<Divider />
|
<Divider />
|
||||||
<VStack vAlign="center" padding={3} ref={loadMoreRef}>
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
{isFetchingNextPage || hasNextPage ? (
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
<LoaderWithImage />
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
) : (
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
rows.length
|
||||||
No more orders
|
)} of ${rows.length} order${rows.length === 1 ? '' : 's'}`}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
<Pagination
|
||||||
</VStack>
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(p) => table.setPageIndex(p - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Orders details pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -19,12 +19,34 @@ import {
|
|||||||
MdOutlineLocalShipping,
|
MdOutlineLocalShipping,
|
||||||
MdOutlinePendingActions,
|
MdOutlinePendingActions,
|
||||||
MdOutlineCheckCircle,
|
MdOutlineCheckCircle,
|
||||||
MdOutlineCurrencyRupee
|
MdOutlineCurrencyRupee,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use. This
|
||||||
|
// table's two-row grouped header (Orders/Deliveries each spanning 3 leaf
|
||||||
|
// columns) uses TanStack's native column-grouping (`columns: [...]` nested
|
||||||
|
// under a parent column) — `header.rowSpan`/`header.colSpan` are computed
|
||||||
|
// automatically from the group depth, so the header render loop below skips
|
||||||
|
// any header reporting `rowSpan === 0` (its cell was already merged into a
|
||||||
|
// taller cell above it) and renders every other header with its own
|
||||||
|
// rowSpan/colSpan, per table-core's documented placeholder-chain contract.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
@@ -36,6 +58,7 @@ import { Badge } from '@astryxdesign/core/Badge';
|
|||||||
import { Divider } from '@astryxdesign/core/Divider';
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import { getreportlocationsummary, getreportsummary, gettenantlocations, getTenants } from 'pages/api/api';
|
import { getreportlocationsummary, getreportsummary, gettenantlocations, getTenants } from 'pages/api/api';
|
||||||
@@ -124,6 +147,31 @@ function formatNumberToRupees(value) {
|
|||||||
}).format(Number(value) || 0);
|
}).format(Number(value) || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly. Sorting + pagination only
|
||||||
|
// here — deliberately NO columnSizingFeature/columnResizingFeature: this
|
||||||
|
// table's 2-row grouped header (Orders/Deliveries each colSpan={3}) makes a
|
||||||
|
// per-column resize handle ambiguous (which row does the handle belong to?
|
||||||
|
// what does resizing a group header even mean?), a materially fiddlier UX
|
||||||
|
// problem than the other converted pages. Sorting/pagination carry the same
|
||||||
|
// value without that complication.
|
||||||
|
const ORDERS_SUMMARY_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Orders Summary
|
// Orders Summary
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -144,6 +192,9 @@ export default function OrdersReport() {
|
|||||||
const [searchword, setSearchword] = useState('');
|
const [searchword, setSearchword] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [, setPage] = useState(0);
|
const [, setPage] = useState(0);
|
||||||
|
// Table sorting/pagination state — mirrors orders.js's/riders.js's slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
|
||||||
// Clear nested filters when scope widens.
|
// Clear nested filters when scope widens.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -360,6 +411,242 @@ export default function OrdersReport() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A zone/tenant/location/date/search change re-scopes the list, so page 1
|
||||||
|
// is the only page that still means anything — same reset orders.js/
|
||||||
|
// riders.js do on their own tables.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [appId, tenantid, locationid, startdate, enddate, debouncedSearch]);
|
||||||
|
|
||||||
|
const ORDERS_GROUP_BG = tint('#ef4444');
|
||||||
|
const DELIVERIES_GROUP_BG = tint('#10b981');
|
||||||
|
|
||||||
|
// The column model. Orders/Deliveries are TanStack column GROUPS (a
|
||||||
|
// `columns:` array nested under a parent column def) — this is what
|
||||||
|
// produces the two-row header: `table.getHeaderGroups()` returns one row
|
||||||
|
// per depth level, and TanStack computes each header's rowSpan/colSpan
|
||||||
|
// from the group structure automatically (see the import comment above for
|
||||||
|
// the placeholder-chain contract the render loop follows).
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'group',
|
||||||
|
accessorFn: (row) => (isLocationGroup ? row.locationname : row.tenantname) || '—',
|
||||||
|
size: 220,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: isLocationGroup ? 'Location' : 'Tenant' },
|
||||||
|
header: () => <SectionLabel>{isLocationGroup ? 'Location' : 'Tenant'}</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<AccentAvatar color={isLocationGroup ? '#0ea5e9' : BRAND} size={36}>
|
||||||
|
{isLocationGroup ? <MdLocationOn size={18} /> : <MdStore size={18} />}
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
ID #{isLocationGroup ? row.original.locationid : row.original.tenantid}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'all',
|
||||||
|
accessorKey: 'totalorders',
|
||||||
|
size: 100,
|
||||||
|
minSize: 80,
|
||||||
|
meta: { label: 'All', align: 'center' },
|
||||||
|
header: () => <SectionLabel>All</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color={BRAND} icon={<MdLocalShipping size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ordersGroup',
|
||||||
|
header: () => <SectionLabel>Orders</SectionLabel>,
|
||||||
|
meta: { headerBg: ORDERS_GROUP_BG, headerColor: '#ef4444' },
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: 'orderspending',
|
||||||
|
accessorKey: 'Orderspending',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Orders · Pending', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#f59e0b' },
|
||||||
|
header: () => <SectionLabel>Pending</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orderscancelled',
|
||||||
|
accessorKey: 'orderscancelled',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Orders · Cancelled', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#ef4444' },
|
||||||
|
header: () => <SectionLabel>Cancelled</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#ef4444" icon={<MdCancel size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orderscompleted',
|
||||||
|
accessorKey: 'orderscompleted',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Orders · Completed', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#10b981' },
|
||||||
|
header: () => <SectionLabel>Completed</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#10b981" icon={<MdCheckCircle size={11} />} />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deliveriesGroup',
|
||||||
|
header: () => <SectionLabel>Deliveries</SectionLabel>,
|
||||||
|
meta: { headerBg: DELIVERIES_GROUP_BG, headerColor: '#10b981' },
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
id: 'deliveriespending',
|
||||||
|
accessorKey: 'deliveriespending',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Deliveries · Pending', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#f59e0b' },
|
||||||
|
header: () => <SectionLabel>Pending</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deliveriescancelled',
|
||||||
|
accessorKey: 'deliveriescancelled',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Deliveries · Cancelled', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#ef4444' },
|
||||||
|
header: () => <SectionLabel>Cancelled</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#ef4444" icon={<MdCancel size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'deliveriescompleted',
|
||||||
|
accessorKey: 'deliveriescompleted',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Deliveries · Completed', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#10b981' },
|
||||||
|
header: () => <SectionLabel>Completed</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#10b981" icon={<MdCheckCircle size={11} />} />
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'collection',
|
||||||
|
accessorKey: 'collectionamt',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Collection', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Collection</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<MetricPill
|
||||||
|
color="#f59e0b"
|
||||||
|
icon={<MdCurrencyRupee size={11} />}
|
||||||
|
label={Number(getValue() || 0).toFixed(2)}
|
||||||
|
tooltip="Collection Amount"
|
||||||
|
minWidth={90}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kmsActual',
|
||||||
|
accessorKey: 'kms',
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'KMS / Actual', align: 'center' },
|
||||||
|
header: () => <SectionLabel>KMS / Actual</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<VStack gap={1} padding={0} vAlign="center">
|
||||||
|
<MetricPill
|
||||||
|
color="#ef4444"
|
||||||
|
icon={<MdStraighten size={11} />}
|
||||||
|
label={`${Number(row.original.kms || 0).toFixed(2)} km`}
|
||||||
|
tooltip="KMS"
|
||||||
|
/>
|
||||||
|
<MetricPill
|
||||||
|
color="#10b981"
|
||||||
|
icon={<MdStraighten size={11} />}
|
||||||
|
label={`${Number(row.original.cumulativekms || 0).toFixed(2)} km`}
|
||||||
|
tooltip="Actual KMS"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amount',
|
||||||
|
accessorFn: (row) => Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0),
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Amount', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Amount</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<MetricPill
|
||||||
|
color={BRAND}
|
||||||
|
icon={<MdCurrencyRupee size={11} />}
|
||||||
|
label={formatNumberToRupees(getValue()).replace('₹', '').trim()}
|
||||||
|
tooltip="Total Amount"
|
||||||
|
minWidth={100}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action',
|
||||||
|
size: 90,
|
||||||
|
minSize: 70,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: 'Action' },
|
||||||
|
header: () => <SectionLabel>Action</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const rowKey = isLocationGroup ? r.locationname : r.tenantname;
|
||||||
|
const isOpen = openRow === rowKey;
|
||||||
|
return (
|
||||||
|
<HStack justify="end">
|
||||||
|
<IconButton
|
||||||
|
label={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
|
||||||
|
tooltip={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
|
||||||
|
size="sm"
|
||||||
|
variant={isOpen ? 'primary' : 'ghost'}
|
||||||
|
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
|
||||||
|
onClick={() => toggleBreakdown(r, rowKey)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[isLocationGroup, openRow, pagination.pageIndex, pagination.pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: ORDERS_SUMMARY_TABLE_FEATURES,
|
||||||
|
// filteredRows is already useMemo'd above (stable reference) — required
|
||||||
|
// for pagination clicks to stick, see riders.js's fix for the same bug.
|
||||||
|
data: filteredRows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String((isLocationGroup ? row.locationname : row.tenantname) ?? `row-${index}`),
|
||||||
|
state: { sorting, pagination },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination
|
||||||
|
});
|
||||||
|
|
||||||
|
// Leaf-column count (not top-level `columns.length`, which mixes 7 leaves
|
||||||
|
// with 2 group parents) — this is what loading/empty-state colSpans need
|
||||||
|
// to actually span every rendered data column.
|
||||||
|
const colCount = table.getAllLeafColumns().length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(loading || isLoadingReports) && <Loader />}
|
{(loading || isLoadingReports) && <Loader />}
|
||||||
@@ -400,9 +687,9 @@ export default function OrdersReport() {
|
|||||||
not a lifecycle list), so the toolbar just holds every filter
|
not a lifecycle list), so the toolbar just holds every filter
|
||||||
including search — same shape as hubs.js/vehicles.js when there's
|
including search — same shape as hubs.js/vehicles.js when there's
|
||||||
nothing to pair search with. The two-row grouped Orders/Deliveries
|
nothing to pair search with. The two-row grouped Orders/Deliveries
|
||||||
header below (rowSpan/colSpan) stays a hand-rolled table rather
|
header is a TanStack Table v9 column-group (see the `columns`
|
||||||
than a TanStack Table v9 column-group conversion — a materially
|
useMemo above) — table-core computes rowSpan/colSpan from the
|
||||||
larger, riskier change than this pass's chrome scope. */}
|
group structure, the render loop below just follows it. */}
|
||||||
<DataCard
|
<DataCard
|
||||||
toolbar={
|
toolbar={
|
||||||
<FilterToolbar max={4} minWidth={200}>
|
<FilterToolbar max={4} minWidth={200}>
|
||||||
@@ -486,7 +773,11 @@ export default function OrdersReport() {
|
|||||||
|
|
||||||
{!isLoadingReports && filteredRows.length > 0 && (
|
{!isLoadingReports && filteredRows.length > 0 && (
|
||||||
<MobileCardList scroll>
|
<MobileCardList scroll>
|
||||||
{filteredRows.map((row, index) => {
|
{/* Same paginated + sorted row model the desktop table
|
||||||
|
reads, so mobile and desktop never disagree on which
|
||||||
|
page is showing for the same filters. */}
|
||||||
|
{table.getRowModel().rows.map((tableRow) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const rowKey = isLocationGroup ? row.locationname : row.tenantname;
|
const rowKey = isLocationGroup ? row.locationname : row.tenantname;
|
||||||
const isOpen = openRow === rowKey;
|
const isOpen = openRow === rowKey;
|
||||||
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
|
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
|
||||||
@@ -494,7 +785,7 @@ export default function OrdersReport() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={`${rowKey}-${index}`}
|
key={tableRow.id}
|
||||||
accent={rowAccent}
|
accent={rowAccent}
|
||||||
selected={isOpen}
|
selected={isOpen}
|
||||||
header={
|
header={
|
||||||
@@ -592,59 +883,78 @@ export default function OrdersReport() {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={1200}>
|
<TableScroll minWidth={1200}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
<Table density="balanced" dividers="rows" hasHover style={{ width: '100%' }} aria-label="Orders Summary">
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell rowSpan={2}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell rowSpan={2}>{isLocationGroup ? 'Location' : 'Tenant'}</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
// Placeholder chain: a leaf column shallower than the
|
||||||
All
|
// table's max depth gets padded with placeholders;
|
||||||
</TableHeaderCell>
|
// every header covering the same visual cell after
|
||||||
<TableHeaderCell colSpan={3} style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}>
|
// the first reports rowSpan 0 and must be skipped —
|
||||||
Orders
|
// rendering it would draw an extra empty <th>.
|
||||||
</TableHeaderCell>
|
if (header.rowSpan === 0) return null;
|
||||||
<TableHeaderCell colSpan={3} style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}>
|
const meta = header.column.columnDef.meta;
|
||||||
Deliveries
|
const align = meta?.align;
|
||||||
</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
const sorted = header.column.getIsSorted();
|
||||||
Collection
|
return (
|
||||||
</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
key={header.id}
|
||||||
KMS / Actual
|
scope="col"
|
||||||
</TableHeaderCell>
|
rowSpan={header.rowSpan}
|
||||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
colSpan={header.colSpan}
|
||||||
Amount
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
</TableHeaderCell>
|
style={{
|
||||||
<TableHeaderCell rowSpan={2}>Action</TableHeaderCell>
|
textAlign: align,
|
||||||
</TableRow>
|
backgroundColor: meta?.headerBg,
|
||||||
<TableRow>
|
color: meta?.headerColor
|
||||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#f59e0b' }}>
|
}}
|
||||||
Pending
|
>
|
||||||
</TableHeaderCell>
|
{header.isPlaceholder ? null : (
|
||||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}>
|
<HStack
|
||||||
Cancelled
|
gap={1}
|
||||||
</TableHeaderCell>
|
vAlign="center"
|
||||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#10b981' }}>
|
justify={align === 'center' ? 'center' : 'start'}
|
||||||
Completed
|
role={canSort ? 'button' : undefined}
|
||||||
</TableHeaderCell>
|
tabIndex={canSort ? 0 : undefined}
|
||||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#f59e0b' }}>
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
Pending
|
onKeyDown={
|
||||||
</TableHeaderCell>
|
canSort
|
||||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#ef4444' }}>
|
? (e) => {
|
||||||
Cancelled
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
</TableHeaderCell>
|
e.preventDefault();
|
||||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}>
|
header.column.toggleSorting();
|
||||||
Completed
|
}
|
||||||
</TableHeaderCell>
|
}
|
||||||
</TableRow>
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoadingReports && <TableLoader rows={5} columns={13} />}
|
{isLoadingReports && <TableLoader rows={5} columns={colCount} />}
|
||||||
|
|
||||||
{!isLoadingReports && filteredRows.length === 0 && (
|
{!isLoadingReports && filteredRows.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={13}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title={`No ${isLocationGroup ? 'locations' : 'tenants'} to show`}
|
title={`No ${isLocationGroup ? 'locations' : 'tenants'} to show`}
|
||||||
description={
|
description={
|
||||||
@@ -660,111 +970,24 @@ export default function OrdersReport() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredRows.map((row, index) => {
|
{table.getRowModel().rows.map((tableRow) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const rowKey = isLocationGroup ? row.locationname : row.tenantname;
|
const rowKey = isLocationGroup ? row.locationname : row.tenantname;
|
||||||
const isOpen = openRow === rowKey;
|
const isOpen = openRow === rowKey;
|
||||||
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={`${rowKey}-${index}`}>
|
<React.Fragment key={tableRow.id}>
|
||||||
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
|
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
|
||||||
<TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
<TableCell key={cell.id} style={{ textAlign: cell.column.columnDef.meta?.align }}>
|
||||||
{String(index + 1).padStart(2, '0')}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</Text>
|
</TableCell>
|
||||||
</TableCell>
|
))}
|
||||||
<TableCell>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<AccentAvatar color={isLocationGroup ? '#0ea5e9' : BRAND} size={36}>
|
|
||||||
{isLocationGroup ? <MdLocationOn size={18} /> : <MdStore size={18} />}
|
|
||||||
</AccentAvatar>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{(isLocationGroup ? row.locationname : row.tenantname) || '—'}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
ID #{isLocationGroup ? row.locationid : row.tenantid}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.totalorders} color={BRAND} icon={<MdLocalShipping size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.Orderspending} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.orderscancelled} color="#ef4444" icon={<MdCancel size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.orderscompleted} color="#10b981" icon={<MdCheckCircle size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.deliveriespending} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.deliveriescancelled} color="#ef4444" icon={<MdCancel size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<CountCell value={row.deliveriescompleted} color="#10b981" icon={<MdCheckCircle size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<MetricPill
|
|
||||||
color="#f59e0b"
|
|
||||||
icon={<MdCurrencyRupee size={11} />}
|
|
||||||
label={Number(row.collectionamt || 0).toFixed(2)}
|
|
||||||
tooltip="Collection Amount"
|
|
||||||
minWidth={90}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<VStack gap={1} padding={0} vAlign="center">
|
|
||||||
<MetricPill
|
|
||||||
color="#ef4444"
|
|
||||||
icon={<MdStraighten size={11} />}
|
|
||||||
label={`${Number(row.kms || 0).toFixed(2)} km`}
|
|
||||||
tooltip="KMS"
|
|
||||||
/>
|
|
||||||
<MetricPill
|
|
||||||
color="#10b981"
|
|
||||||
icon={<MdStraighten size={11} />}
|
|
||||||
label={`${Number(row.cumulativekms || 0).toFixed(2)} km`}
|
|
||||||
tooltip="Actual KMS"
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<MetricPill
|
|
||||||
color={BRAND}
|
|
||||||
icon={<MdCurrencyRupee size={11} />}
|
|
||||||
label={formatNumberToRupees(amount).replace('₹', '').trim()}
|
|
||||||
tooltip="Total Amount"
|
|
||||||
minWidth={100}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<HStack justify="end">
|
|
||||||
<IconButton
|
|
||||||
label={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
|
|
||||||
tooltip={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
|
|
||||||
size="sm"
|
|
||||||
variant={isOpen ? 'primary' : 'ghost'}
|
|
||||||
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
|
|
||||||
onClick={() => toggleBreakdown(row, rowKey)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={13} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
<TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
||||||
<div style={{ padding: 12 }}>
|
<div style={{ padding: 12 }}>
|
||||||
<RiderBreakdown label={isLocationGroup ? row.locationname : row.tenantname} />
|
<RiderBreakdown label={isLocationGroup ? row.locationname : row.tenantname} />
|
||||||
</div>
|
</div>
|
||||||
@@ -779,8 +1002,31 @@ export default function OrdersReport() {
|
|||||||
</TableScroll>
|
</TableScroll>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination sits above the Grand Total bar — the total is
|
||||||
|
deliberately computed from the FULL filtered set (`stats`,
|
||||||
|
above), not just the current page, so it keeps summing every
|
||||||
|
matching row regardless of which page is showing. */}
|
||||||
{filteredRows.length > 0 && (
|
{filteredRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
filteredRows.length
|
||||||
|
)} of ${filteredRows.length} ${isLocationGroup ? 'location' : 'tenant'}${filteredRows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(p) => table.setPageIndex(p - 1)}
|
||||||
|
totalItems={filteredRows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Orders summary pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
<Divider />
|
<Divider />
|
||||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" padding={2} style={{ background: '#ffffff' }}>
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" padding={2} style={{ background: '#ffffff' }}>
|
||||||
<HStack gap={2} vAlign="center" wrap="wrap">
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import {
|
import {
|
||||||
@@ -17,15 +17,34 @@ import {
|
|||||||
MdPerson,
|
MdPerson,
|
||||||
MdOutlineLocalShipping,
|
MdOutlineLocalShipping,
|
||||||
MdOutlineCheckCircle,
|
MdOutlineCheckCircle,
|
||||||
MdOutlineCurrencyRupee
|
MdOutlineCurrencyRupee,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
} from 'react-icons/md';
|
} from 'react-icons/md';
|
||||||
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
@@ -33,6 +52,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip';
|
|||||||
import { Divider } from '@astryxdesign/core/Divider';
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import { fetchRidersSummary } from 'pages/api/api';
|
import { fetchRidersSummary } from 'pages/api/api';
|
||||||
@@ -121,6 +141,29 @@ function formatNumberToRupees(value) {
|
|||||||
}).format(Number(value) || 0);
|
}).format(Number(value) || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const RIDERS_SUMMARY_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
// ==============================|| Riders Summary ||============================== //
|
// ==============================|| Riders Summary ||============================== //
|
||||||
|
|
||||||
export default function RidersSummary() {
|
export default function RidersSummary() {
|
||||||
@@ -141,6 +184,11 @@ export default function RidersSummary() {
|
|||||||
const [routeLoading, setRouteLoading] = useState(false);
|
const [routeLoading, setRouteLoading] = useState(false);
|
||||||
const [searchword, setSearchword] = useState('');
|
const [searchword, setSearchword] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
|
// slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
// ==============================|| fetchRidersSummary ||============================== //
|
// ==============================|| fetchRidersSummary ||============================== //
|
||||||
const { isLoading: isLoadingReports, data: rows } = useQuery({
|
const { isLoading: isLoadingReports, data: rows } = useQuery({
|
||||||
@@ -162,6 +210,12 @@ export default function RidersSummary() {
|
|||||||
return rows.filter((r) => [r.displayname, String(r.userid)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q)));
|
return rows.filter((r) => [r.displayname, String(r.userid)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q)));
|
||||||
}, [rows, debouncedSearch]);
|
}, [rows, debouncedSearch]);
|
||||||
|
|
||||||
|
// A zone/date/search change re-scopes the list, so page 1 is the only page
|
||||||
|
// that still means anything — same reset orders.js/riders.js do.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [appId, startdate, enddate, debouncedSearch]);
|
||||||
|
|
||||||
// Aggregate KPIs from the loaded summary.
|
// Aggregate KPIs from the loaded summary.
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 };
|
if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 };
|
||||||
@@ -287,6 +341,169 @@ export default function RidersSummary() {
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as
|
||||||
|
// orders.js's/riders.js's/Tenants.js's `columns`. Row-expand state
|
||||||
|
// (openRow) was already keyed by `row.userid`, not position, so it stays
|
||||||
|
// correct once sorting/pagination reorder rows — no change needed there.
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rider',
|
||||||
|
accessorFn: (row) => row.displayname || '—',
|
||||||
|
size: 220,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Rider' },
|
||||||
|
header: () => <SectionLabel>Rider</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<AccentAvatar color={BRAND} size={36}>
|
||||||
|
<MdPerson size={18} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
ID #{row.original.userid}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'orders',
|
||||||
|
accessorKey: 'assigned',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Orders' },
|
||||||
|
header: () => <SectionLabel>Orders</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color={BRAND} icon={<MdLocalShipping size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'accepted',
|
||||||
|
accessorKey: 'accepted',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Accepted' },
|
||||||
|
header: () => <SectionLabel>Accepted</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cancelled',
|
||||||
|
accessorKey: 'cancelled',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Cancelled' },
|
||||||
|
header: () => <SectionLabel>Cancelled</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#ef4444" icon={<MdCancel size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delivered',
|
||||||
|
accessorKey: 'delivered',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Delivered' },
|
||||||
|
header: () => <SectionLabel>Delivered</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <CountCell value={getValue()} color="#10b981" icon={<MdCheckCircle size={11} />} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'kms',
|
||||||
|
accessorKey: 'riderkms',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Kms' },
|
||||||
|
header: () => <SectionLabel>Kms</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<MetricPill color="#ef4444" icon={<MdStraighten size={11} />} label={`${Number(getValue() || 0).toFixed(2)} km`} tooltip="KMS" />
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'amount',
|
||||||
|
accessorFn: (row) => Number(row.ridercharges) || 0,
|
||||||
|
size: 140,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Amount' },
|
||||||
|
header: () => <SectionLabel>Amount</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<MetricPill
|
||||||
|
color={BRAND}
|
||||||
|
icon={<MdCurrencyRupee size={11} />}
|
||||||
|
label={formatNumberToRupees(getValue()).replace('₹', '').trim()}
|
||||||
|
tooltip="Total Amount"
|
||||||
|
minWidth={100}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'action',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Action' },
|
||||||
|
header: () => <SectionLabel>Action</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const r = row.original;
|
||||||
|
const isOpen = openRow === r.userid;
|
||||||
|
return (
|
||||||
|
<HStack gap={0.75} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="View planned route"
|
||||||
|
tooltip="View planned route"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdMap size={14} />}
|
||||||
|
onClick={() => openRoutePreview(r)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
|
||||||
|
tooltip={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
|
||||||
|
size="sm"
|
||||||
|
variant={isOpen ? 'primary' : 'ghost'}
|
||||||
|
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
|
||||||
|
onClick={() => toggleBreakdown(r)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[pagination.pageIndex, pagination.pageSize, openRow]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: RIDERS_SUMMARY_TABLE_FEATURES,
|
||||||
|
// filteredRows is already useMemo'd above (stable reference) — required
|
||||||
|
// for pagination clicks to stick, see riders.js's fix for the same bug.
|
||||||
|
data: filteredRows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.userid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoadingReports || loading) && <Loader />}
|
{(isLoadingReports || loading) && <Loader />}
|
||||||
@@ -350,13 +567,17 @@ export default function RidersSummary() {
|
|||||||
icon={<MdDirectionsBike size={28} />}
|
icon={<MdDirectionsBike size={28} />}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
filteredRows.map((row, index) => {
|
// Same paginated + sorted row model the desktop table reads,
|
||||||
|
// so mobile and desktop never disagree on what page is
|
||||||
|
// showing for the same filters.
|
||||||
|
table.getRowModel().rows.map((tableRow, index) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const isOpen = openRow === row.userid;
|
const isOpen = openRow === row.userid;
|
||||||
const amount = Number(row.ridercharges) || 0;
|
const amount = Number(row.ridercharges) || 0;
|
||||||
const riderName = row?.displayname || '—';
|
const riderName = row?.displayname || '—';
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={row.userid || index}
|
key={tableRow.id}
|
||||||
accent={BRAND}
|
accent={BRAND}
|
||||||
selected={isOpen}
|
selected={isOpen}
|
||||||
header={
|
header={
|
||||||
@@ -370,7 +591,7 @@ export default function RidersSummary() {
|
|||||||
{riderName}
|
{riderName}
|
||||||
</Text>
|
</Text>
|
||||||
<Text type="supporting" color="secondary">
|
<Text type="supporting" color="secondary">
|
||||||
#{String(index + 1).padStart(2, '0')} · ID #{row.userid}
|
#{String(pagination.pageIndex * pagination.pageSize + index + 1).padStart(2, '0')} · ID #{row.userid}
|
||||||
</Text>
|
</Text>
|
||||||
</VStack>
|
</VStack>
|
||||||
</HStack>
|
</HStack>
|
||||||
@@ -434,27 +655,108 @@ export default function RidersSummary() {
|
|||||||
)}
|
)}
|
||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={1080}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Riders Summary"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Rider</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Orders</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Accepted</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Cancelled</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Delivered</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>KMS</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell>Amount</TableHeaderCell>
|
key={header.id}
|
||||||
<TableHeaderCell>Action</TableHeaderCell>
|
scope="col"
|
||||||
</TableRow>
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
|
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoadingReports && <TableLoader rows={5} columns={9} />}
|
{isLoadingReports && <TableLoader rows={5} columns={colCount} />}
|
||||||
{(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? (
|
{(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No riders to show"
|
title="No riders to show"
|
||||||
description={searchword ? 'Try a different keyword.' : 'Pick a zone or date range to load the summary.'}
|
description={searchword ? 'Try a different keyword.' : 'Pick a zone or date range to load the summary.'}
|
||||||
@@ -463,91 +765,28 @@ export default function RidersSummary() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
filteredRows.map((row, index) => {
|
table.getRowModel().rows.map((tableRow) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const isOpen = openRow === row.userid;
|
const isOpen = openRow === row.userid;
|
||||||
const amount = Number(row.ridercharges) || 0;
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment key={row.userid || index}>
|
<React.Fragment key={tableRow.id}>
|
||||||
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
|
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
|
||||||
<TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
<TableCell
|
||||||
{String(index + 1).padStart(2, '0')}
|
key={cell.id}
|
||||||
</Text>
|
style={{
|
||||||
</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell>
|
}}
|
||||||
<HStack gap={2} vAlign="center">
|
>
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<MdPerson size={18} />
|
</TableCell>
|
||||||
</AccentAvatar>
|
))}
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{row?.displayname || '—'}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
ID #{row.userid}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<CountCell value={row.assigned} color={BRAND} icon={<MdLocalShipping size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<CountCell value={row.accepted} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<CountCell value={row.cancelled} color="#ef4444" icon={<MdCancel size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<CountCell value={row.delivered} color="#10b981" icon={<MdCheckCircle size={11} />} />
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<MetricPill
|
|
||||||
color="#ef4444"
|
|
||||||
icon={<MdStraighten size={11} />}
|
|
||||||
label={`${Number(row.riderkms || 0).toFixed(2)} km`}
|
|
||||||
tooltip="KMS"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<MetricPill
|
|
||||||
color={BRAND}
|
|
||||||
icon={<MdCurrencyRupee size={11} />}
|
|
||||||
label={formatNumberToRupees(amount).replace('₹', '').trim()}
|
|
||||||
tooltip="Total Amount"
|
|
||||||
minWidth={100}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<HStack gap={0.75} justify="end">
|
|
||||||
<IconButton
|
|
||||||
label="View planned route"
|
|
||||||
tooltip="View planned route"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdMap size={14} />}
|
|
||||||
onClick={() => openRoutePreview(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
|
|
||||||
tooltip={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
|
|
||||||
size="sm"
|
|
||||||
variant={isOpen ? 'primary' : 'ghost'}
|
|
||||||
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
|
|
||||||
onClick={() => toggleBreakdown(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
<TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
||||||
<div style={{ padding: 12 }}>
|
<div style={{ padding: 12 }}>
|
||||||
<TenantBreakdown label={row?.displayname || ''} />
|
<TenantBreakdown label={row?.displayname || ''} />
|
||||||
</div>
|
</div>
|
||||||
@@ -563,8 +802,31 @@ export default function RidersSummary() {
|
|||||||
</TableScroll>
|
</TableScroll>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination sits above the Grand Total bar — the total is
|
||||||
|
deliberately computed from the FULL filtered set (`total`,
|
||||||
|
above), not just the current page, so it keeps summing every
|
||||||
|
matching rider regardless of which page is showing. */}
|
||||||
{filteredRows.length > 0 && (
|
{filteredRows.length > 0 && (
|
||||||
<>
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
filteredRows.length
|
||||||
|
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(p) => table.setPageIndex(p - 1)}
|
||||||
|
totalItems={filteredRows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Riders summary pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
<Divider />
|
<Divider />
|
||||||
<HStack justify="end" vAlign="center" gap={2} padding={2} style={{ background: '#ffffff' }}>
|
<HStack justify="end" vAlign="center" gap={2} padding={2} style={{ background: '#ffffff' }}>
|
||||||
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import { MdCheckCircle, MdCancel, MdAccessTime, MdInventory2, MdTwoWheeler, MdArrowForward } from 'react-icons/md';
|
import {
|
||||||
|
MdCheckCircle,
|
||||||
|
MdCancel,
|
||||||
|
MdAccessTime,
|
||||||
|
MdInventory2,
|
||||||
|
MdTwoWheeler,
|
||||||
|
MdArrowForward,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
|
} from 'react-icons/md';
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { Badge } from '@astryxdesign/core/Badge';
|
import { Badge } from '@astryxdesign/core/Badge';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
||||||
@@ -22,6 +49,29 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
|
|||||||
|
|
||||||
const BRAND = DT.brand;
|
const BRAND = DT.brand;
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, matching
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables).
|
||||||
|
const SUBSTITUTION_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const SUBSTITUTION_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const STATUS_META = {
|
const STATUS_META = {
|
||||||
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
|
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
|
||||||
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
|
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
|
||||||
@@ -55,6 +105,11 @@ export default function RiderSubstitution({
|
|||||||
const isMobile = useIsNarrowViewport();
|
const isMobile = useIsNarrowViewport();
|
||||||
const [selectedBatch, setSelectedBatch] = useState('all');
|
const [selectedBatch, setSelectedBatch] = useState('all');
|
||||||
const [isFinalizing, setIsFinalizing] = useState(false);
|
const [isFinalizing, setIsFinalizing] = useState(false);
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
|
// slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const getBatchForTime = React.useCallback((timeStr) => {
|
const getBatchForTime = React.useCallback((timeStr) => {
|
||||||
if (!timeStr) return 'unknown';
|
if (!timeStr) return 'unknown';
|
||||||
@@ -136,6 +191,12 @@ export default function RiderSubstitution({
|
|||||||
return (rows || []).filter((row) => getShiftBatch(row.starttime) === selectedBatch);
|
return (rows || []).filter((row) => getShiftBatch(row.starttime) === selectedBatch);
|
||||||
}, [rows, selectedBatch]);
|
}, [rows, selectedBatch]);
|
||||||
|
|
||||||
|
// A batch-filter change re-scopes the list, so page 1 is the only page
|
||||||
|
// that still means anything.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [selectedBatch]);
|
||||||
|
|
||||||
const hasAssignments = Object.values(substituteAssignments || {}).some((val) => val !== null && val !== undefined);
|
const hasAssignments = Object.values(substituteAssignments || {}).some((val) => val !== null && val !== undefined);
|
||||||
|
|
||||||
// The /substitutions resource has no equivalent in the new Doormile Express
|
// The /substitutions resource has no equivalent in the new Doormile Express
|
||||||
@@ -176,6 +237,133 @@ export default function RiderSubstitution({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The column model — declared fresh every render (not useMemo'd): the
|
||||||
|
// 'substitute' and 'status' cells close over allRidersList/
|
||||||
|
// isBatchDisabled/substituteAssignments/getRowStatusMeta, which change
|
||||||
|
// often, and memoizing here would need an unmanageable dependency list
|
||||||
|
// (same tradeoff Tenants.js's actions column made). Recomputing ~6 column
|
||||||
|
// defs a render is cheap.
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
accessorKey: 'userid',
|
||||||
|
size: 90,
|
||||||
|
minSize: 70,
|
||||||
|
meta: { label: 'ID' },
|
||||||
|
header: () => <SectionLabel>ID</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: '#ffffff',
|
||||||
|
border: `1px solid ${edge(BRAND)}`,
|
||||||
|
color: BRAND,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 11,
|
||||||
|
minWidth: 56
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{getValue()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rider',
|
||||||
|
accessorFn: (row) => row.username || row.fullname || '—',
|
||||||
|
size: 220,
|
||||||
|
minSize: 180,
|
||||||
|
meta: { label: 'Rider' },
|
||||||
|
header: () => <SectionLabel>Rider</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<HStack gap={2} vAlign="center">
|
||||||
|
<AccentAvatar color={BRAND} selected size={36}>
|
||||||
|
<Text weight="bold">{(row.original.fullname || row.original.username || '?').charAt(0).toUpperCase()}</Text>
|
||||||
|
</AccentAvatar>
|
||||||
|
<VStack gap={0} padding={0} minWidth={0}>
|
||||||
|
<Text weight="semibold">{getValue()}</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{row.original.contactno || '—'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'arrow',
|
||||||
|
size: 56,
|
||||||
|
minSize: 48,
|
||||||
|
maxSize: 64,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '', align: 'center' },
|
||||||
|
header: () => null,
|
||||||
|
cell: () => <MdArrowForward size={18} color={DT.textSecondary} />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'substitute',
|
||||||
|
size: 240,
|
||||||
|
minSize: 200,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Substitute Rider' },
|
||||||
|
header: () => <SectionLabel>Substitute Rider</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const riderProfile = allRidersList?.find((r) => r.userid === row.original.userid);
|
||||||
|
const starttime = riderProfile?.starttime || row.original.starttime;
|
||||||
|
return renderSubstitutePicker(row.original, isBatchDisabled(getBatchForTime(starttime)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorFn: (row) => ((row?.status || '').toLowerCase() === 'active' ? 'active' : 'inactive'),
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Status', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = getRowStatusMeta(row.original);
|
||||||
|
const Icon = meta.icon;
|
||||||
|
return <Badge variant={meta.badge} icon={<Icon size={12} />} label={meta.label} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: SUBSTITUTION_TABLE_FEATURES,
|
||||||
|
data: filteredRows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.userid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Read off columns.length directly (not table.getVisibleLeafColumns()):
|
||||||
|
// that method requires columnVisibilityFeature, which this table doesn't
|
||||||
|
// register (no column-hide menu here).
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
const filterRow = (
|
const filterRow = (
|
||||||
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
|
<HStack justify="between" vAlign="center" gap={3} wrap="wrap">
|
||||||
<HStack gap={2} vAlign="center" wrap="wrap">
|
<HStack gap={2} vAlign="center" wrap="wrap">
|
||||||
@@ -205,13 +393,14 @@ export default function RiderSubstitution({
|
|||||||
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
|
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
|
||||||
) : (
|
) : (
|
||||||
<MobileCardList scroll>
|
<MobileCardList scroll>
|
||||||
{filteredRows?.map((row, index) => {
|
{table.getRowModel().rows.map((tableRow, index) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const statusMeta = getRowStatusMeta(row);
|
const statusMeta = getRowStatusMeta(row);
|
||||||
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
|
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
|
||||||
const starttime = riderProfile?.starttime || row.starttime;
|
const starttime = riderProfile?.starttime || row.starttime;
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={row.userid ?? index}
|
key={tableRow.id ?? index}
|
||||||
accent={statusMeta.color}
|
accent={statusMeta.color}
|
||||||
header={
|
header={
|
||||||
<VStack gap={2} padding={0}>
|
<VStack gap={2} padding={0}>
|
||||||
@@ -265,89 +454,160 @@ export default function RiderSubstitution({
|
|||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={1200}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Rider substitutes"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>ID</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Rider</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Substitute Rider</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
|
return (
|
||||||
</TableRow>
|
<TableHeaderCell
|
||||||
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{filteredRows?.length === 0 && (
|
{filteredRows?.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
|
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredRows?.length !== 0 &&
|
{filteredRows?.length !== 0 &&
|
||||||
filteredRows?.map((row, index) => {
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
const statusMeta = getRowStatusMeta(row);
|
<TableRow key={tableRow.id}>
|
||||||
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
|
{tableRow.getAllCells().map((cell) => (
|
||||||
const starttime = riderProfile?.starttime || row.starttime;
|
<TableCell
|
||||||
return (
|
key={cell.id}
|
||||||
<TableRow key={row.userid ?? index}>
|
style={{
|
||||||
<TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
{String(index + 1).padStart(2, '0')}
|
}}
|
||||||
</Text>
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
))}
|
||||||
<div
|
</TableRow>
|
||||||
style={{
|
))}
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '3px 8px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: '#ffffff',
|
|
||||||
border: `1px solid ${edge(BRAND)}`,
|
|
||||||
color: BRAND,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 11,
|
|
||||||
minWidth: 56
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
#{row?.userid}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<HStack gap={2} vAlign="center">
|
|
||||||
<AccentAvatar color={BRAND} selected size={36}>
|
|
||||||
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
|
|
||||||
</AccentAvatar>
|
|
||||||
<VStack gap={0} padding={0} minWidth={0}>
|
|
||||||
<Text weight="semibold">{row.username || '—'}</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.contactno || '—'}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<MdArrowForward size={18} color={DT.textSecondary} />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ minWidth: 220 }}>
|
|
||||||
{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Page-number pagination, matching orders.js's/riders.js's/
|
||||||
|
Tenants.js's footer shape. Currently near-moot (rows is always []
|
||||||
|
— see the module comment on filteredRows), but wired correctly for
|
||||||
|
when the /substitutions resource gets a real backend. */}
|
||||||
|
{filteredRows?.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={3} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
filteredRows.length
|
||||||
|
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={filteredRows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={SUBSTITUTION_PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Rider substitutes pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{hasAssignments && (
|
{hasAssignments && (
|
||||||
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
|
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
import { useState, useEffect, useRef, Fragment } from 'react';
|
import { useState, useEffect, useMemo, Fragment } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
var utc = require('dayjs/plugin/utc');
|
var utc = require('dayjs/plugin/utc');
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
// TanStack Table v9 — same engine orders.js uses, so sorting/resizing/
|
||||||
|
// pagination behave identically across the two pages.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
import {
|
import {
|
||||||
MdCheckCircle,
|
MdCheckCircle,
|
||||||
MdCancel,
|
MdCancel,
|
||||||
@@ -14,6 +28,9 @@ import {
|
|||||||
MdEdit,
|
MdEdit,
|
||||||
MdKeyboardArrowDown,
|
MdKeyboardArrowDown,
|
||||||
MdKeyboardArrowUp,
|
MdKeyboardArrowUp,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore,
|
||||||
MdLocationOn,
|
MdLocationOn,
|
||||||
MdBatteryStd,
|
MdBatteryStd,
|
||||||
MdPowerSettingsNew,
|
MdPowerSettingsNew,
|
||||||
@@ -32,6 +49,7 @@ import { Grid } from '@astryxdesign/core/Grid';
|
|||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
import { Card } from '@astryxdesign/core/Card';
|
import { Card } from '@astryxdesign/core/Card';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||||
@@ -42,8 +60,10 @@ import { TabList, Tab } from '@astryxdesign/core/TabList';
|
|||||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
||||||
@@ -107,6 +127,29 @@ const KPI_META = (summary) => [
|
|||||||
{ key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 }
|
{ key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly, same pattern orders.js
|
||||||
|
// uses — only sorting/resizing/pagination ship here (no row-selection or
|
||||||
|
// column-visibility, since this table has no bulk-select flow).
|
||||||
|
const RIDERS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const RIDERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's table uses.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
// Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip
|
// Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip
|
||||||
// pattern from the pricing page so the telemetry block reads at a glance.
|
// pattern from the pricing page so the telemetry block reads at a glance.
|
||||||
const LogChip = ({ color, icon: Icon, label, value }) => (
|
const LogChip = ({ color, icon: Icon, label, value }) => (
|
||||||
@@ -182,15 +225,17 @@ const Riders = () => {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const isMobile = useIsNarrowViewport();
|
const isMobile = useIsNarrowViewport();
|
||||||
const loadMoreRef = useRef();
|
|
||||||
const [searchword, setSearchword] = useState('');
|
const [searchword, setSearchword] = useState('');
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [locaName, setLocoName] = useState('All');
|
const [locaName, setLocoName] = useState('All');
|
||||||
const [appId, setAppId] = useState(0);
|
const [appId, setAppId] = useState(0);
|
||||||
const [tabvalue, setTabvalue] = useState(0);
|
const [tabvalue, setTabvalue] = useState(0);
|
||||||
const roleid = localStorage.getItem('roleid');
|
const roleid = localStorage.getItem('roleid');
|
||||||
const totalCols = roleid == 1 ? 11 : 10;
|
|
||||||
const [logsRow, setLogsRow] = useState(null);
|
const [logsRow, setLogsRow] = useState(null);
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
const [riderLogsdata, setRiderLogsdata] = useState(null);
|
const [riderLogsdata, setRiderLogsdata] = useState(null);
|
||||||
const [historyDate, setHistoryDate] = useState(dayjs());
|
const [historyDate, setHistoryDate] = useState(dayjs());
|
||||||
const [historyStatus, setHistoryStatus] = useState('all');
|
const [historyStatus, setHistoryStatus] = useState('all');
|
||||||
@@ -320,62 +365,55 @@ const Riders = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| fetchAllRiders||============================== //
|
// ==============================|| fetchAllRiders||============================== //
|
||||||
// GET /admin/milers isn't paginated (fetchAllRiders always returns the full
|
// GET /admin/milers isn't paginated — fetchAllRiders always returns the
|
||||||
// list in one shot) — getNextPageParam must not request a "next page" or it
|
// full list in one shot (`{ details, nextPage: undefined }`). Previously
|
||||||
// re-fetches and appends the identical full list again, duplicating every
|
// wrapped in useInfiniteQuery with a getNextPageParam that always returned
|
||||||
// rider in `rows`.
|
// undefined, so "loading more" never actually fetched a second page — a
|
||||||
const {
|
// plain bulk fetch, same shape orders.js uses for bookings, with the table
|
||||||
data: allRidersData,
|
// below paginating the already-complete list client-side.
|
||||||
isLoading: allRidersLoading,
|
const { data: allRidersResult, isLoading: allRidersLoading } = useQuery({
|
||||||
isFetchingNextPage,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ['allriders', appId, debouncedSearch, tabvalue],
|
queryKey: ['allriders', appId, debouncedSearch, tabvalue],
|
||||||
queryFn: fetchAllRiders,
|
queryFn: fetchAllRiders
|
||||||
getNextPageParam: () => undefined
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const rows = (allRidersData?.pages.flatMap((page) => page.details || []) || []).filter(Boolean);
|
const rows = useMemo(() => (allRidersResult?.details || []).filter(Boolean), [allRidersResult]);
|
||||||
|
|
||||||
// GET /admin/milers accepts no search/status params — debouncedSearch and
|
// GET /admin/milers accepts no search/status params — debouncedSearch and
|
||||||
// tabvalue only trigger a refetch (via the queryKey) that returns the exact
|
// tabvalue only trigger a refetch (via the queryKey) that returns the exact
|
||||||
// same full list every time. Search and the "Active" tab were both silently
|
// same full list every time. Search and the "Active" tab were both silently
|
||||||
// no-ops until filtered client-side here.
|
// no-ops until filtered client-side here.
|
||||||
const filteredRows = rows.filter((row) => {
|
//
|
||||||
// "Active" here means currently online/working (Available/Assigned/
|
// Memoized (unlike the pre-TanStack version of this file) because the table
|
||||||
// On_Pickup) — Offline and Blocked (confirmed live enum) both mean not
|
// reads this as `data`: TanStack memoizes its core row model on the `data`
|
||||||
// working.
|
// REFERENCE and auto-resets `pageIndex` to 0 whenever that reference
|
||||||
const status = String(row.availabilitystatus || '').toLowerCase();
|
// changes. An unmemoized `.filter()` returns a new array every render —
|
||||||
if (tabvalue === 1 && (status === 'offline' || status === 'blocked')) return false;
|
// including the very render a page-2/3/4 click causes — so pagination
|
||||||
if (!debouncedSearch) return true;
|
// looked broken (every click snapped straight back to page 1). orders.js's
|
||||||
const q = debouncedSearch.toLowerCase();
|
// `filteredRows` was already memoized for the same reason.
|
||||||
return [row.displayname, row.phone].some((v) =>
|
const filteredRows = useMemo(
|
||||||
String(v || '')
|
() =>
|
||||||
.toLowerCase()
|
rows.filter((row) => {
|
||||||
.includes(q)
|
// "Active" here means currently online/working (Available/Assigned/
|
||||||
);
|
// On_Pickup) — Offline and Blocked (confirmed live enum) both mean
|
||||||
});
|
// not working.
|
||||||
|
const status = String(row.availabilitystatus || '').toLowerCase();
|
||||||
|
if (tabvalue === 1 && (status === 'offline' || status === 'blocked')) return false;
|
||||||
|
if (!debouncedSearch) return true;
|
||||||
|
const q = debouncedSearch.toLowerCase();
|
||||||
|
return [row.displayname, row.phone].some((v) =>
|
||||||
|
String(v || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(q)
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
[rows, tabvalue, debouncedSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// A tab switch or search re-scopes the list, so page 1 is the only page
|
||||||
|
// that still means anything — same reset orders.js does on its own table.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasNextPage) return;
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
const observer = new IntersectionObserver(
|
}, [tabvalue, debouncedSearch]);
|
||||||
(entries) => {
|
|
||||||
if (entries[0].isIntersecting) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
root: document.querySelector('.dt-table-scroll'),
|
|
||||||
rootMargin: '0px',
|
|
||||||
threshold: 1.0
|
|
||||||
}
|
|
||||||
);
|
|
||||||
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
|
|
||||||
return () => {
|
|
||||||
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
|
|
||||||
};
|
|
||||||
}, [hasNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
// Per-row status meta — falls back to "unknown" if the rider's state key
|
// Per-row status meta — falls back to "unknown" if the rider's state key
|
||||||
// isn't in the palette (e.g. brand-new status string from the backend).
|
// isn't in the palette (e.g. brand-new status string from the backend).
|
||||||
@@ -501,6 +539,222 @@ const Riders = () => {
|
|||||||
</HStack>
|
</HStack>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// The column model — everything a column IS (id, accessor, header, cell,
|
||||||
|
// width, sortability) declared once here instead of split across a
|
||||||
|
// hardcoded header row and a hand-written body row, same shape as
|
||||||
|
// orders.js's `columns`. The Action column is appended conditionally
|
||||||
|
// because it only exists for roleid 1, not hidden via a visibility slice.
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const base = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 40,
|
||||||
|
minSize: 32,
|
||||||
|
maxSize: 48,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
accessorKey: 'userid',
|
||||||
|
size: 90,
|
||||||
|
minSize: 70,
|
||||||
|
meta: { label: 'ID' },
|
||||||
|
header: () => <SectionLabel>ID</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '3px 8px',
|
||||||
|
borderRadius: 999,
|
||||||
|
background: '#ffffff',
|
||||||
|
border: `1px solid ${edge(BRAND)}`,
|
||||||
|
color: BRAND,
|
||||||
|
fontWeight: 800,
|
||||||
|
fontSize: 11,
|
||||||
|
minWidth: 56
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
#{getValue()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rider',
|
||||||
|
accessorFn: (row) => row.displayname || row.authname || '—',
|
||||||
|
size: 200,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Rider' },
|
||||||
|
header: () => <SectionLabel>Rider</SectionLabel>,
|
||||||
|
cell: ({ getValue, row }) => (
|
||||||
|
<VStack gap={0} padding={0} minWidth={0}>
|
||||||
|
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||||
|
{getValue()}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{row.original.phone || '—'}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'address',
|
||||||
|
accessorFn: (row) => row.suburb || row.address || '',
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Address' },
|
||||||
|
header: () => <SectionLabel>Address</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Tooltip content={row.original.address || ''}>
|
||||||
|
<VStack gap={0} padding={0}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" maxLines={1}>
|
||||||
|
{row.original.suburb || (row.original.address ? row.original.address.slice(0, 20) + '…' : '—')}
|
||||||
|
</Text>
|
||||||
|
<Text type="supporting" color="secondary" maxLines={1}>
|
||||||
|
{row.original.city || ''}
|
||||||
|
</Text>
|
||||||
|
</VStack>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicle',
|
||||||
|
accessorKey: 'defaultvehicletype',
|
||||||
|
size: 140,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Vehicle' },
|
||||||
|
header: () => <SectionLabel>Vehicle</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<HStack gap={1} vAlign="center">
|
||||||
|
<AccentAvatar color="#0ea5e9" size={22}>
|
||||||
|
<MdTwoWheeler size={12} />
|
||||||
|
</AccentAvatar>
|
||||||
|
<Text type="supporting" weight="bold">
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'shift',
|
||||||
|
accessorKey: 'shiftid',
|
||||||
|
size: 90,
|
||||||
|
minSize: 70,
|
||||||
|
meta: { label: 'Shift' },
|
||||||
|
header: () => <SectionLabel>Shift</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary">
|
||||||
|
#{getValue() ?? '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'time',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
enableSorting: false,
|
||||||
|
meta: { label: 'Time', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Time</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<VStack gap={0.5} padding={0} vAlign="center">
|
||||||
|
<Badge
|
||||||
|
variant="green"
|
||||||
|
label={row.original.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.original.starttime}`).format('hh:mm A') : '—'}
|
||||||
|
/>
|
||||||
|
<Badge
|
||||||
|
variant="red"
|
||||||
|
label={row.original.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.original.endtime}`).format('hh:mm A') : '—'}
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fare',
|
||||||
|
accessorKey: 'basefare',
|
||||||
|
size: 100,
|
||||||
|
minSize: 80,
|
||||||
|
meta: { label: 'Fare' },
|
||||||
|
header: () => <SectionLabel>Fare</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text type="supporting" weight="bold">
|
||||||
|
{getValue() ?? '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fuel',
|
||||||
|
accessorKey: 'fuelcharge',
|
||||||
|
size: 100,
|
||||||
|
minSize: 80,
|
||||||
|
meta: { label: 'Fuel' },
|
||||||
|
header: () => <SectionLabel>Fuel</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text type="supporting" weight="bold">
|
||||||
|
{getValue() ?? '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'availabilitystatus',
|
||||||
|
size: 130,
|
||||||
|
minSize: 110,
|
||||||
|
meta: { label: 'Status', align: 'center' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const meta = getRowStatusMeta(row.original);
|
||||||
|
return <Badge variant={meta.badge} icon={<meta.icon size={12} />} label={meta.label} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
if (roleid == 1) {
|
||||||
|
base.push({
|
||||||
|
id: 'action',
|
||||||
|
size: 150,
|
||||||
|
minSize: 120,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Action' },
|
||||||
|
header: () => <SectionLabel>Action</SectionLabel>,
|
||||||
|
cell: ({ row }) => <RowActions row={row.original} expanded={logsRow === row.original.milerprofileid} />
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return base;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [roleid, logsRow, pagination.pageIndex, pagination.pageSize]);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: RIDERS_TABLE_FEATURES,
|
||||||
|
// The full filtered set — the table paginates it, same pattern
|
||||||
|
// orders.js uses for its (also client-filtered) bookings list.
|
||||||
|
data: filteredRows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.userid ?? row.milerprofileid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered — the Action column only exists for roleid 1. Read off
|
||||||
|
// `columns` directly (not `table.getVisibleLeafColumns()`): that method
|
||||||
|
// only exists when columnVisibilityFeature is registered, which this
|
||||||
|
// table doesn't need (unlike orders.js — there is no column-hide menu here).
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && (
|
{(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && (
|
||||||
@@ -532,19 +786,12 @@ const Riders = () => {
|
|||||||
</KpiGrid>
|
</KpiGrid>
|
||||||
|
|
||||||
<DataCard
|
<DataCard
|
||||||
|
// History-only controls (status filter + date) get their own row —
|
||||||
|
// ONLY on that tab, so the other three tabs never show an empty
|
||||||
|
// toolbar strip above the tabs+search row.
|
||||||
toolbar={
|
toolbar={
|
||||||
<FilterToolbar max={tabvalue === 3 ? 3 : 1}>
|
tabvalue === 3 ? (
|
||||||
<HStack justify="end">
|
<FilterToolbar max={2}>
|
||||||
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
|
|
||||||
<DebounceSearchBar
|
|
||||||
value={searchword}
|
|
||||||
onChange={setSearchword}
|
|
||||||
onDebouncedChange={setDebouncedSearch}
|
|
||||||
placeholder="Search riders (ctrl+k)"
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</HStack>
|
|
||||||
{tabvalue === 3 && (
|
|
||||||
<HStack gap={2} vAlign="center">
|
<HStack gap={2} vAlign="center">
|
||||||
<Text weight="bold" color="secondary">
|
<Text weight="bold" color="secondary">
|
||||||
Status:
|
Status:
|
||||||
@@ -555,8 +802,6 @@ const Riders = () => {
|
|||||||
))}
|
))}
|
||||||
</SegmentedControl>
|
</SegmentedControl>
|
||||||
</HStack>
|
</HStack>
|
||||||
)}
|
|
||||||
{tabvalue === 3 && (
|
|
||||||
<DateInput
|
<DateInput
|
||||||
label="Select Date"
|
label="Select Date"
|
||||||
isLabelHidden
|
isLabelHidden
|
||||||
@@ -564,30 +809,45 @@ const Riders = () => {
|
|||||||
value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined}
|
value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined}
|
||||||
onChange={(v) => v && setHistoryDate(dayjs(v))}
|
onChange={(v) => v && setHistoryDate(dayjs(v))}
|
||||||
/>
|
/>
|
||||||
)}
|
</FilterToolbar>
|
||||||
</FilterToolbar>
|
) : undefined
|
||||||
}
|
}
|
||||||
|
// Tabs and search share ONE row (same pattern orders.js uses) —
|
||||||
|
// previously the search box lived in its own toolbar row above the
|
||||||
|
// tab strip, which read as two stacked rows instead of one control
|
||||||
|
// strip.
|
||||||
tabs={
|
tabs={
|
||||||
<TabList value={String(tabvalue)} onChange={(v) => handleChangetab(Number(v))} size="sm">
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
|
||||||
{TAB_META.map((t) => {
|
<TabList value={String(tabvalue)} onChange={(v) => handleChangetab(Number(v))} size="sm">
|
||||||
const Icon = t.icon;
|
{TAB_META.map((t) => {
|
||||||
const count =
|
const Icon = t.icon;
|
||||||
t.key === 2
|
const count =
|
||||||
? subRidersLogsData?.length || 0
|
t.key === 2
|
||||||
: t.key === 3
|
? subRidersLogsData?.length || 0
|
||||||
? subHistoryData?.length || 0
|
: t.key === 3
|
||||||
: allRidersSummary?.[t.countKey] ?? 0;
|
? subHistoryData?.length || 0
|
||||||
return (
|
: allRidersSummary?.[t.countKey] ?? 0;
|
||||||
<Tab
|
return (
|
||||||
key={t.key}
|
<Tab
|
||||||
value={String(t.key)}
|
key={t.key}
|
||||||
label={t.label}
|
value={String(t.key)}
|
||||||
icon={<Icon size={15} />}
|
label={t.label}
|
||||||
endContent={<Badge variant="neutral" label={count} />}
|
icon={<Icon size={15} />}
|
||||||
/>
|
endContent={<Badge variant="neutral" label={count} />}
|
||||||
);
|
/>
|
||||||
})}
|
);
|
||||||
</TabList>
|
})}
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
|
||||||
|
<DebounceSearchBar
|
||||||
|
value={searchword}
|
||||||
|
onChange={setSearchword}
|
||||||
|
onDebouncedChange={setDebouncedSearch}
|
||||||
|
placeholder="Search riders (ctrl+k)"
|
||||||
|
/>
|
||||||
|
</VStack>
|
||||||
|
</HStack>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{tabvalue === 3 ? (
|
{tabvalue === 3 ? (
|
||||||
@@ -769,13 +1029,17 @@ const Riders = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Same paginated + sorted row model the desktop table
|
||||||
|
reads, so a mobile viewer and a desktop viewer never see
|
||||||
|
a different page of riders for the same filters. */}
|
||||||
{filteredRows?.length !== 0 &&
|
{filteredRows?.length !== 0 &&
|
||||||
filteredRows?.map((row, index) => {
|
table.getRowModel().rows.map((tableRow, index) => {
|
||||||
|
const row = tableRow.original;
|
||||||
const statusMeta = getRowStatusMeta(row);
|
const statusMeta = getRowStatusMeta(row);
|
||||||
const expanded = logsRow === row.milerprofileid;
|
const expanded = logsRow === row.milerprofileid;
|
||||||
return (
|
return (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={row.userid ?? index}
|
key={tableRow.id ?? index}
|
||||||
accent={statusMeta.color}
|
accent={statusMeta.color}
|
||||||
selected={expanded}
|
selected={expanded}
|
||||||
header={
|
header={
|
||||||
@@ -859,43 +1123,110 @@ const Riders = () => {
|
|||||||
</MobileCard>
|
</MobileCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{filteredRows?.length !== 0 && (
|
|
||||||
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
|
|
||||||
{isFetchingNextPage || hasNextPage ? (
|
|
||||||
<LoaderWithImage />
|
|
||||||
) : (
|
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
|
||||||
No more riders
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</MobileCardList>
|
</MobileCardList>
|
||||||
) : (
|
) : (
|
||||||
<TableScroll minWidth={1300}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's table.
|
||||||
|
Header and body are both rendered FROM the column
|
||||||
|
model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Riders"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>ID</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Rider</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Address</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Vehicle</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Shift</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Time</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell>Fare</TableHeaderCell>
|
key={header.id}
|
||||||
<TableHeaderCell>Fuel</TableHeaderCell>
|
scope="col"
|
||||||
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
{roleid == 1 && <TableHeaderCell>Action</TableHeaderCell>}
|
style={{
|
||||||
</TableRow>
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{allRidersLoading && <TableLoader rows={6} columns={totalCols} />}
|
{allRidersLoading && <TableLoader rows={6} columns={colCount} />}
|
||||||
|
|
||||||
{filteredRows?.length === 0 && !allRidersLoading && (
|
{filteredRows?.length === 0 && !allRidersLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={totalCols}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No riders to show"
|
title="No riders to show"
|
||||||
description={
|
description={
|
||||||
@@ -908,110 +1239,32 @@ const Riders = () => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{filteredRows?.length !== 0 &&
|
{filteredRows?.length !== 0 &&
|
||||||
filteredRows?.map((row, index) => {
|
table.getRowModel().rows.map((tableRow) => {
|
||||||
const statusMeta = getRowStatusMeta(row);
|
const row = tableRow.original;
|
||||||
const expanded = logsRow === row.milerprofileid;
|
const expanded = logsRow === row.milerprofileid;
|
||||||
return (
|
return (
|
||||||
<Fragment key={row.userid ?? index}>
|
<Fragment key={tableRow.id}>
|
||||||
<TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}>
|
<TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}>
|
||||||
<TableCell>
|
{/* getAllCells (core), not getVisibleCells —
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
that one is gated behind
|
||||||
{String(index + 1).padStart(2, '0')}
|
columnVisibilityFeature, which this table
|
||||||
</Text>
|
doesn't register. */}
|
||||||
</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>
|
<TableCell
|
||||||
<div
|
key={cell.id}
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-flex',
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
alignItems: 'center',
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '3px 8px',
|
|
||||||
borderRadius: 999,
|
|
||||||
background: '#ffffff',
|
|
||||||
border: `1px solid ${edge(BRAND)}`,
|
|
||||||
color: BRAND,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 11,
|
|
||||||
minWidth: 56
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
#{row?.userid}
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<VStack gap={0} padding={0} minWidth={0}>
|
|
||||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
|
||||||
{row.displayname || row.authname || '—'}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary">
|
|
||||||
{row.phone || '—'}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ maxWidth: 220 }}>
|
|
||||||
<Tooltip content={row.address || ''}>
|
|
||||||
<VStack gap={0} padding={0}>
|
|
||||||
<Text type="supporting" weight="bold" color="secondary" maxLines={1}>
|
|
||||||
{row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')}
|
|
||||||
</Text>
|
|
||||||
<Text type="supporting" color="secondary" maxLines={1}>
|
|
||||||
{row.city || ''}
|
|
||||||
</Text>
|
|
||||||
</VStack>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<HStack gap={1} vAlign="center">
|
|
||||||
<AccentAvatar color="#0ea5e9" size={22}>
|
|
||||||
<MdTwoWheeler size={12} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<Text type="supporting" weight="bold">
|
|
||||||
{row.defaultvehicletype || '—'}
|
|
||||||
</Text>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
|
||||||
#{row.shiftid ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<VStack gap={0.5} padding={0} vAlign="center">
|
|
||||||
<Badge
|
|
||||||
variant="green"
|
|
||||||
label={
|
|
||||||
row.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.starttime}`).format('hh:mm A') : '—'
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Badge
|
|
||||||
variant="red"
|
|
||||||
label={row.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.endtime}`).format('hh:mm A') : '—'}
|
|
||||||
/>
|
|
||||||
</VStack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Text type="supporting" weight="bold">
|
|
||||||
{row.basefare ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Text type="supporting" weight="bold">
|
|
||||||
{row.fuelcharge ?? '—'}
|
|
||||||
</Text>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell style={{ textAlign: 'center' }}>
|
|
||||||
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
|
|
||||||
</TableCell>
|
|
||||||
{roleid == 1 && (
|
|
||||||
<TableCell>
|
|
||||||
<RowActions row={row} expanded={expanded} />
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
)}
|
))}
|
||||||
</TableRow>
|
</TableRow>
|
||||||
|
|
||||||
{expanded && tabvalue !== 0 && (
|
{expanded && tabvalue !== 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={totalCols} style={{ padding: 12 }}>
|
<TableCell colSpan={colCount} style={{ padding: 12 }}>
|
||||||
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
|
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -1019,26 +1272,39 @@ const Riders = () => {
|
|||||||
</Fragment>
|
</Fragment>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{filteredRows?.length !== 0 && (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={totalCols}>
|
|
||||||
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
|
|
||||||
{isFetchingNextPage || hasNextPage ? (
|
|
||||||
<LoaderWithImage />
|
|
||||||
) : (
|
|
||||||
<Text type="supporting" weight="bold" color="secondary">
|
|
||||||
No more riders
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
)}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pagination replaces the old scroll-to-reveal sentinel — the
|
||||||
|
API returns the full rider list in one shot, so this was
|
||||||
|
always paginating an in-memory array, just without any UI
|
||||||
|
for it. Shared between mobile and desktop since both read
|
||||||
|
the same `table` row model above. */}
|
||||||
|
{filteredRows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
filteredRows.length
|
||||||
|
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={filteredRows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={RIDERS_PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Riders pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
|
|||||||
@@ -1,17 +1,44 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md';
|
import {
|
||||||
|
MdOutlineLocalShipping,
|
||||||
|
MdAdd,
|
||||||
|
MdPlaylistAdd,
|
||||||
|
MdOutlineFlightTakeoff,
|
||||||
|
MdOutlineFlightLand,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
|
} from 'react-icons/md';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
import { enqueueSnackbar } from 'notistack';
|
import { enqueueSnackbar } from 'notistack';
|
||||||
|
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
|
|
||||||
import Loader from 'components/Loader';
|
import Loader from 'components/Loader';
|
||||||
@@ -43,12 +70,38 @@ const BRAND = DT.brand;
|
|||||||
|
|
||||||
const emptyForm = { sourceHub: '', destinationHub: '', vehicle: '', driver: '' };
|
const emptyForm = { sourceHub: '', destinationHub: '', vehicle: '', driver: '' };
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const TRIPSHEETS_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const Tripsheets = () => {
|
const Tripsheets = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
const [itemDialogTrip, setItemDialogTrip] = useState(null);
|
const [itemDialogTrip, setItemDialogTrip] = useState(null);
|
||||||
const [itemConsignmentId, setItemConsignmentId] = useState('');
|
const [itemConsignmentId, setItemConsignmentId] = useState('');
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets });
|
const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets });
|
||||||
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||||
@@ -132,6 +185,115 @@ const Tripsheets = () => {
|
|||||||
|
|
||||||
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
|
||||||
|
|
||||||
|
// The column model — deliberately NOT wrapped in useMemo: the Source/
|
||||||
|
// Destination Hub columns' accessorFn closes over `hubMap`, and the
|
||||||
|
// Actions column closes over several mutations, so recomputing this small
|
||||||
|
// (5-column) array every render is simpler and cheaper than maintaining an
|
||||||
|
// exhaustive dependency list.
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'sourceHub',
|
||||||
|
accessorFn: (row) => hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—'),
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Source Hub' },
|
||||||
|
header: () => <SectionLabel>Source Hub</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'destinationHub',
|
||||||
|
accessorFn: (row) => hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—'),
|
||||||
|
size: 220,
|
||||||
|
minSize: 160,
|
||||||
|
meta: { label: 'Destination Hub' },
|
||||||
|
header: () => <SectionLabel>Destination Hub</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
size: 160,
|
||||||
|
minSize: 120,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (
|
||||||
|
<Text type="supporting" color="secondary">
|
||||||
|
{getValue() || '—'}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 170,
|
||||||
|
minSize: 150,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Add item"
|
||||||
|
tooltip="Add item"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdPlaylistAdd size={16} />}
|
||||||
|
onClick={() => setItemDialogTrip(row.original)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Dispatch"
|
||||||
|
tooltip="Dispatch"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdOutlineFlightTakeoff size={16} />}
|
||||||
|
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.original.tripsheetid })}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Arrive"
|
||||||
|
tooltip="Arrive"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdOutlineFlightLand size={16} />}
|
||||||
|
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.original.tripsheetid })}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: TRIPSHEETS_TABLE_FEATURES,
|
||||||
|
data: tripsheets,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.tripsheetid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
|
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
|
||||||
@@ -150,22 +312,107 @@ const Tripsheets = () => {
|
|||||||
</KpiGrid>
|
</KpiGrid>
|
||||||
|
|
||||||
<DataCard>
|
<DataCard>
|
||||||
<TableScroll minWidth={920}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Tripsheets"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Source Hub</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Destination Hub</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
</TableRow>
|
return (
|
||||||
|
<TableHeaderCell
|
||||||
|
key={header.id}
|
||||||
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={5} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{tripsheets.length === 0 && !isLoading ? (
|
{tripsheets.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState
|
<EmptyState
|
||||||
title="No tripsheets to show"
|
title="No tripsheets to show"
|
||||||
description="Create a tripsheet to move consignments between hubs."
|
description="Create a tripsheet to move consignments between hubs."
|
||||||
@@ -174,48 +421,49 @@ const Tripsheets = () => {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
tripsheets.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.tripsheetid || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{index + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
|
<TableCell
|
||||||
<TableCell>
|
key={cell.id}
|
||||||
{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}
|
style={{
|
||||||
</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>{row.status || '—'}</TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
<TableCell>
|
}}
|
||||||
<HStack gap={1} justify="end">
|
>
|
||||||
<IconButton
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
label="Add item"
|
</TableCell>
|
||||||
tooltip="Add item"
|
))}
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdPlaylistAdd size={16} />}
|
|
||||||
onClick={() => setItemDialogTrip(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Dispatch"
|
|
||||||
tooltip="Dispatch"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdOutlineFlightTakeoff size={16} />}
|
|
||||||
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Arrive"
|
|
||||||
tooltip="Arrive"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdOutlineFlightLand size={16} />}
|
|
||||||
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
|
|
||||||
|
{tripsheets.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
tripsheets.length
|
||||||
|
)} of ${tripsheets.length} tripsheet${tripsheets.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={tripsheets.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Tripsheets pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,46 @@
|
|||||||
import React, { useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd, MdBatteryFull } from 'react-icons/md';
|
import {
|
||||||
|
MdDirectionsCar,
|
||||||
|
MdEdit,
|
||||||
|
MdDeleteOutline,
|
||||||
|
MdAdd,
|
||||||
|
MdBatteryStd,
|
||||||
|
MdBatteryFull,
|
||||||
|
MdArrowUpward,
|
||||||
|
MdArrowDownward,
|
||||||
|
MdUnfoldMore
|
||||||
|
} from 'react-icons/md';
|
||||||
|
|
||||||
|
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
|
||||||
|
// sorting/resizing/pagination behave identically across the console's list
|
||||||
|
// pages.
|
||||||
|
import {
|
||||||
|
useTable,
|
||||||
|
flexRender,
|
||||||
|
tableFeatures,
|
||||||
|
rowSortingFeature,
|
||||||
|
createSortedRowModel,
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
createPaginatedRowModel
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
|
||||||
import { Grid } from '@astryxdesign/core/Grid';
|
import { Grid } from '@astryxdesign/core/Grid';
|
||||||
import { HStack } from '@astryxdesign/core/HStack';
|
import { HStack } from '@astryxdesign/core/HStack';
|
||||||
import { VStack } from '@astryxdesign/core/VStack';
|
import { VStack } from '@astryxdesign/core/VStack';
|
||||||
|
import { Center } from '@astryxdesign/core/Center';
|
||||||
import { Text } from '@astryxdesign/core/Text';
|
import { Text } from '@astryxdesign/core/Text';
|
||||||
import { Button } from '@astryxdesign/core/Button';
|
import { Button } from '@astryxdesign/core/Button';
|
||||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||||
|
import { Divider } from '@astryxdesign/core/Divider';
|
||||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||||
import { Selector } from '@astryxdesign/core/Selector';
|
import { Selector } from '@astryxdesign/core/Selector';
|
||||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||||
|
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
@@ -29,6 +58,29 @@ import { TableScroll } from 'themes/dt/primitives';
|
|||||||
const BRAND = DT.brand;
|
const BRAND = DT.brand;
|
||||||
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
|
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
|
||||||
|
|
||||||
|
// TanStack Table v9 registers features explicitly — only sorting/resizing/
|
||||||
|
// pagination ship here (no row-selection or column-visibility, since this
|
||||||
|
// table has no bulk-select or column-hide flow).
|
||||||
|
const VEHICLES_TABLE_FEATURES = tableFeatures({
|
||||||
|
rowSortingFeature,
|
||||||
|
sortedRowModel: createSortedRowModel(),
|
||||||
|
sortFns,
|
||||||
|
columnSizingFeature,
|
||||||
|
columnResizingFeature,
|
||||||
|
rowPaginationFeature,
|
||||||
|
paginatedRowModel: createPaginatedRowModel()
|
||||||
|
});
|
||||||
|
|
||||||
|
const VEHICLES_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
|
||||||
|
|
||||||
|
// Uppercase micro-label for the table's column headings — same convention
|
||||||
|
// orders.js's/riders.js's/Tenants.js's tables use.
|
||||||
|
const SectionLabel = ({ children }) => (
|
||||||
|
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
|
||||||
|
{children}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
|
||||||
const emptyForm = {
|
const emptyForm = {
|
||||||
vehicleid: null,
|
vehicleid: null,
|
||||||
vehicleno: '',
|
vehicleno: '',
|
||||||
@@ -51,6 +103,11 @@ const Vehicles = () => {
|
|||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
const [form, setForm] = useState(emptyForm);
|
const [form, setForm] = useState(emptyForm);
|
||||||
|
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||||
|
// slices.
|
||||||
|
const [sorting, setSorting] = useState([]);
|
||||||
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
||||||
|
const [columnSizing, setColumnSizing] = useState({});
|
||||||
|
|
||||||
const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
|
const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
|
||||||
const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
|
const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
|
||||||
@@ -128,6 +185,12 @@ const Vehicles = () => {
|
|||||||
if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid);
|
if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// A search change re-scopes the list, so page 1 is the only page that
|
||||||
|
// still means anything — same reset orders.js/riders.js do on their tables.
|
||||||
|
useEffect(() => {
|
||||||
|
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||||
|
}, [debouncedSearch]);
|
||||||
|
|
||||||
const set = (key) => (value) => setForm((fm) => ({ ...fm, [key]: value }));
|
const set = (key) => (value) => setForm((fm) => ({ ...fm, [key]: value }));
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
@@ -148,6 +211,136 @@ const Vehicles = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The column model — everything a column IS declared once here instead of
|
||||||
|
// a hardcoded header row + hand-written body row, same shape as orders.js's/
|
||||||
|
// riders.js's/Tenants.js's `columns`.
|
||||||
|
const columns = useMemo(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
id: 'serial',
|
||||||
|
size: 44,
|
||||||
|
minSize: 36,
|
||||||
|
maxSize: 52,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: '#' },
|
||||||
|
header: () => <SectionLabel>#</SectionLabel>,
|
||||||
|
cell: ({ table: t, row }) => (
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicleno',
|
||||||
|
accessorKey: 'vehicleno',
|
||||||
|
size: 160,
|
||||||
|
minSize: 130,
|
||||||
|
meta: { label: 'Vehicle No' },
|
||||||
|
header: () => <SectionLabel>Vehicle No</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicletype',
|
||||||
|
accessorKey: 'vehicletype',
|
||||||
|
size: 130,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Type' },
|
||||||
|
header: () => <SectionLabel>Type</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'partner',
|
||||||
|
accessorFn: (row) => partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : ''),
|
||||||
|
size: 180,
|
||||||
|
minSize: 140,
|
||||||
|
meta: { label: 'Partner' },
|
||||||
|
header: () => <SectionLabel>Partner</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'maxweight',
|
||||||
|
accessorKey: 'maxweight',
|
||||||
|
size: 130,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Max Weight' },
|
||||||
|
header: () => <SectionLabel>Max Weight</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() ?? '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'battery',
|
||||||
|
accessorKey: 'batterypercentage',
|
||||||
|
size: 110,
|
||||||
|
minSize: 90,
|
||||||
|
meta: { label: 'Battery' },
|
||||||
|
header: () => <SectionLabel>Battery</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => (getValue() != null ? `${getValue()}%` : '—')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'status',
|
||||||
|
accessorKey: 'status',
|
||||||
|
size: 130,
|
||||||
|
minSize: 100,
|
||||||
|
meta: { label: 'Status' },
|
||||||
|
header: () => <SectionLabel>Status</SectionLabel>,
|
||||||
|
cell: ({ getValue }) => getValue() || '—'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
size: 120,
|
||||||
|
minSize: 100,
|
||||||
|
enableSorting: false,
|
||||||
|
enableResizing: false,
|
||||||
|
meta: { label: 'Actions', align: 'right' },
|
||||||
|
header: () => <SectionLabel>Actions</SectionLabel>,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<HStack gap={1} justify="end">
|
||||||
|
<IconButton
|
||||||
|
label="Edit vehicle"
|
||||||
|
tooltip="Edit vehicle"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
icon={<MdEdit size={16} />}
|
||||||
|
onClick={() => openEdit(row.original)}
|
||||||
|
/>
|
||||||
|
<IconButton
|
||||||
|
label="Delete vehicle"
|
||||||
|
tooltip="Delete vehicle"
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
icon={<MdDeleteOutline size={16} />}
|
||||||
|
onClick={() => handleDelete(row.original)}
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[partnerMap, pagination.pageIndex, pagination.pageSize]
|
||||||
|
);
|
||||||
|
|
||||||
|
const table = useTable({
|
||||||
|
features: VEHICLES_TABLE_FEATURES,
|
||||||
|
// `rows` is already useMemo'd above (stable reference) — required for
|
||||||
|
// TanStack's row-model memoization to hold and for pagination clicks to
|
||||||
|
// stick. An unmemoized array here reads as "data changed" on every
|
||||||
|
// render and auto-resets to page 1.
|
||||||
|
data: rows,
|
||||||
|
columns,
|
||||||
|
getRowId: (row, index) => String(row.vehicleid ?? `row-${index}`),
|
||||||
|
columnResizeMode: 'onChange',
|
||||||
|
state: { sorting, pagination, columnSizing },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
onColumnSizingChange: setColumnSizing
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skeleton rows and the empty state span exactly the columns that are
|
||||||
|
// actually rendered. Read off `columns.length` directly (not
|
||||||
|
// `table.getVisibleLeafColumns()`): that method only exists when
|
||||||
|
// columnVisibilityFeature is registered, which this table doesn't need.
|
||||||
|
const colCount = columns.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||||
@@ -188,68 +381,154 @@ const Vehicles = () => {
|
|||||||
</FilterToolbar>
|
</FilterToolbar>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<TableScroll minWidth={960}>
|
<TableScroll minWidth={table.getTotalSize()}>
|
||||||
<Table density="balanced" dividers="rows" hasHover>
|
{/* table-layout: fixed makes the declared column widths
|
||||||
|
authoritative — same reasoning as orders.js's/riders.js's/
|
||||||
|
Tenants.js's tables. Header and body are both rendered FROM
|
||||||
|
the column model, so they cannot drift apart. */}
|
||||||
|
<Table
|
||||||
|
density="balanced"
|
||||||
|
dividers="rows"
|
||||||
|
hasHover
|
||||||
|
verticalAlign="middle"
|
||||||
|
aria-label="Vehicles"
|
||||||
|
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
<TableRow key={headerGroup.id} isHeaderRow>
|
||||||
<TableHeaderCell>Vehicle No</TableHeaderCell>
|
{headerGroup.headers.map((header) => {
|
||||||
<TableHeaderCell>Type</TableHeaderCell>
|
const align = header.column.columnDef.meta?.align;
|
||||||
<TableHeaderCell>Partner</TableHeaderCell>
|
const canSort = header.column.getCanSort();
|
||||||
<TableHeaderCell>Max Weight</TableHeaderCell>
|
const sorted = header.column.getIsSorted();
|
||||||
<TableHeaderCell>Battery</TableHeaderCell>
|
return (
|
||||||
<TableHeaderCell>Status</TableHeaderCell>
|
<TableHeaderCell
|
||||||
<TableHeaderCell>Actions</TableHeaderCell>
|
key={header.id}
|
||||||
</TableRow>
|
scope="col"
|
||||||
|
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||||
|
style={{
|
||||||
|
width: header.getSize(),
|
||||||
|
minWidth: header.column.columnDef.minSize,
|
||||||
|
maxWidth: header.column.columnDef.maxSize || header.getSize(),
|
||||||
|
textAlign: align,
|
||||||
|
position: 'relative'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HStack
|
||||||
|
gap={1}
|
||||||
|
vAlign="center"
|
||||||
|
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
|
||||||
|
role={canSort ? 'button' : undefined}
|
||||||
|
tabIndex={canSort ? 0 : undefined}
|
||||||
|
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
|
||||||
|
onKeyDown={
|
||||||
|
canSort
|
||||||
|
? (e) => {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
header.column.toggleSorting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
|
||||||
|
>
|
||||||
|
{flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
{canSort &&
|
||||||
|
(sorted === 'asc' ? (
|
||||||
|
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : sorted === 'desc' ? (
|
||||||
|
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
|
||||||
|
) : (
|
||||||
|
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||||
|
))}
|
||||||
|
</HStack>
|
||||||
|
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<Center
|
||||||
|
aria-hidden="true"
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
height: '100%',
|
||||||
|
width: 10,
|
||||||
|
cursor: 'col-resize',
|
||||||
|
userSelect: 'none',
|
||||||
|
touchAction: 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Center
|
||||||
|
style={{
|
||||||
|
width: 2,
|
||||||
|
height: '60%',
|
||||||
|
borderRadius: 'var(--radius-element)',
|
||||||
|
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
|
||||||
|
transition: 'background-color 120ms ease'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</TableHeaderCell>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{isLoading && <TableLoader rows={5} columns={8} />}
|
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||||
{rows.length === 0 && !isLoading ? (
|
{rows.length === 0 && !isLoading ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={8}>
|
<TableCell colSpan={colCount}>
|
||||||
<EmptyState title="No vehicles to show" icon={<MdDirectionsCar size={28} />} />
|
<EmptyState title="No vehicles to show" icon={<MdDirectionsCar size={28} />} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : (
|
) : (
|
||||||
rows.map((row, index) => (
|
table.getRowModel().rows.map((tableRow) => (
|
||||||
<TableRow key={row.vehicleid || index}>
|
<TableRow key={tableRow.id}>
|
||||||
<TableCell>{index + 1}</TableCell>
|
{tableRow.getAllCells().map((cell) => (
|
||||||
<TableCell>
|
<TableCell
|
||||||
<Text weight="bold">{row.vehicleno}</Text>
|
key={cell.id}
|
||||||
</TableCell>
|
style={{
|
||||||
<TableCell>{row.vehicletype || '—'}</TableCell>
|
textAlign: cell.column.columnDef.meta?.align,
|
||||||
<TableCell>
|
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||||
{partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}
|
}}
|
||||||
</TableCell>
|
>
|
||||||
<TableCell>{row.maxweight ?? '—'}</TableCell>
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
<TableCell>{row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}</TableCell>
|
</TableCell>
|
||||||
<TableCell>{row.status || '—'}</TableCell>
|
))}
|
||||||
<TableCell>
|
|
||||||
<HStack gap={1} justify="end">
|
|
||||||
<IconButton
|
|
||||||
label="Edit vehicle"
|
|
||||||
tooltip="Edit vehicle"
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
icon={<MdEdit size={16} />}
|
|
||||||
onClick={() => openEdit(row)}
|
|
||||||
/>
|
|
||||||
<IconButton
|
|
||||||
label="Delete vehicle"
|
|
||||||
tooltip="Delete vehicle"
|
|
||||||
size="sm"
|
|
||||||
variant="destructive"
|
|
||||||
icon={<MdDeleteOutline size={16} />}
|
|
||||||
onClick={() => handleDelete(row)}
|
|
||||||
/>
|
|
||||||
</HStack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</TableScroll>
|
</TableScroll>
|
||||||
|
|
||||||
|
{rows.length > 0 && (
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
|
||||||
|
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
|
||||||
|
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
|
||||||
|
(pagination.pageIndex + 1) * pagination.pageSize,
|
||||||
|
rows.length
|
||||||
|
)} of ${rows.length} vehicle${rows.length === 1 ? '' : 's'}`}
|
||||||
|
</Text>
|
||||||
|
<Pagination
|
||||||
|
page={pagination.pageIndex + 1}
|
||||||
|
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
|
||||||
|
totalItems={rows.length}
|
||||||
|
pageSize={pagination.pageSize}
|
||||||
|
pageSizeOptions={VEHICLES_PAGE_SIZE_OPTIONS}
|
||||||
|
onPageSizeChange={(size) => table.setPageSize(size)}
|
||||||
|
size="sm"
|
||||||
|
label="Vehicles pagination"
|
||||||
|
/>
|
||||||
|
</HStack>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</DataCard>
|
</DataCard>
|
||||||
</PageShell>
|
</PageShell>
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ const AppUsers = Loadable(lazy(() => import('pages/nearle/appUsers/appUsers')));
|
|||||||
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
|
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
|
||||||
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
|
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
|
||||||
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
|
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
|
||||||
|
const Assistant = Loadable(lazy(() => import('pages/nearle/assistant/assistant')));
|
||||||
|
|
||||||
// ==============================|| MAIN ROUTING ||============================== //
|
// ==============================|| MAIN ROUTING ||============================== //
|
||||||
|
|
||||||
@@ -168,6 +169,10 @@ const MainRoutes = {
|
|||||||
{
|
{
|
||||||
path: 'competitive-intel',
|
path: 'competitive-intel',
|
||||||
element: <CompetitiveIntel />
|
element: <CompetitiveIntel />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'assistant',
|
||||||
|
element: <Assistant />
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
46
src/utils/batchBucket.js
Normal file
46
src/utils/batchBucket.js
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { parseDoormileTimestamp } from './doormileTimestamp';
|
||||||
|
|
||||||
|
// Canonical Morning/Afternoon/Evening wave definitions — extracted verbatim
|
||||||
|
// from Dispatch.js's BATCHES_DEFAULT_RAW / getBatchForHour / getRowBatch (see
|
||||||
|
// dispatch/CLAUDE.md §1, "Batch / wave model"). deliveries.js mirrors the
|
||||||
|
// same ranges under its own BATCH_OPTIONS/getRowBatchId. This module exists
|
||||||
|
// so a THIRD consumer (the operator bot) doesn't grow a fourth copy that can
|
||||||
|
// drift from the other two — Dispatch.js and deliveries.js are NOT migrated
|
||||||
|
// to import this (dispatch/CLAUDE.md is explicit that folder isn't touched
|
||||||
|
// casually); this is purely additive.
|
||||||
|
//
|
||||||
|
// Half-open [startHour, endHour) ranges in LOCAL time. Gaps (8–9am,
|
||||||
|
// 12:30pm–4pm, after 7pm) are intentional — a row in a gap belongs to no
|
||||||
|
// batch, not the nearest one.
|
||||||
|
export const BATCHES = [
|
||||||
|
{ id: 'morning', label: 'Morning Batch', startHour: 0, endHour: 8 },
|
||||||
|
{ id: 'afternoon', label: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
||||||
|
{ id: 'evening', label: 'Evening Batch', startHour: 16, endHour: 19 }
|
||||||
|
];
|
||||||
|
|
||||||
|
export const getBatchForHour = (h) => {
|
||||||
|
for (const b of BATCHES) {
|
||||||
|
if (h >= b.startHour && h < b.endHour) return b.id;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one field batch bucketing uses across the app: a booking's `orderdate`
|
||||||
|
// (== createdat — see fetchDeliveries in api.js). `assigntime` and
|
||||||
|
// `expecteddeliverytime` were both tried and rejected — see dispatch/
|
||||||
|
// CLAUDE.md §1's table for why. Don't bucket on either here either.
|
||||||
|
export const getRowBatchId = (row) => {
|
||||||
|
const t = row?.orderdate;
|
||||||
|
if (!t) return null;
|
||||||
|
const str = String(t).trim();
|
||||||
|
// Skip bare date strings — no time component, would always parse to midnight.
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
||||||
|
const d = parseDoormileTimestamp(t);
|
||||||
|
if (!d.isValid()) return null;
|
||||||
|
// Fractional hour so e.g. 12:45 falls into the slot starting at 12.5, not
|
||||||
|
// the one starting at 12 — d.hour() alone truncates and mis-buckets the
|
||||||
|
// back half of every hour.
|
||||||
|
return getBatchForHour(d.hour() + d.minute() / 60);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBatchLabel = (batchId) => BATCHES.find((b) => b.id === batchId)?.label || batchId;
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"profitability": "Profitability",
|
"profitability": "Profitability",
|
||||||
"dispatch": "Dispatch",
|
"dispatch": "Dispatch",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
|
"assistant": "Assistant",
|
||||||
"fleetops": "Fleet & Ops",
|
"fleetops": "Fleet & Ops",
|
||||||
"hubs": "Hubs",
|
"hubs": "Hubs",
|
||||||
"vehicles": "Vehicles",
|
"vehicles": "Vehicles",
|
||||||
|
|||||||
Reference in New Issue
Block a user