updatess on the ui changs
This commit is contained in:
@@ -32,7 +32,8 @@ import {
|
||||
TruckOutlined,
|
||||
WarningOutlined,
|
||||
RadarChartOutlined,
|
||||
RiseOutlined
|
||||
RiseOutlined,
|
||||
RobotOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
// icons
|
||||
@@ -66,7 +67,8 @@ const icons = {
|
||||
TruckOutlined,
|
||||
WarningOutlined,
|
||||
RadarChartOutlined,
|
||||
RiseOutlined
|
||||
RiseOutlined,
|
||||
RobotOutlined
|
||||
};
|
||||
|
||||
// ==============================|| MENU ITEMS - SUPPORT ||============================== //
|
||||
@@ -126,6 +128,13 @@ const nearle = {
|
||||
url: '/doormile/customers',
|
||||
icon: icons.ContactsOutlined
|
||||
},
|
||||
{
|
||||
id: 'assistant',
|
||||
title: <FormattedMessage id="assistant" />,
|
||||
type: 'item',
|
||||
url: '/doormile/assistant',
|
||||
icon: icons.RobotOutlined
|
||||
},
|
||||
{
|
||||
id: 'fleetops',
|
||||
title: <FormattedMessage id="fleetops" />,
|
||||
|
||||
@@ -71,7 +71,11 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = {
|
||||
delivered: 'delivered',
|
||||
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();
|
||||
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
|
||||
// batch it was actually assigned to and into whichever window contained
|
||||
// the current clock time — the same orders appearing under Afternoon and
|
||||
// then Evening on the same day. Both now bucket on
|
||||
// `expecteddeliverytime`, which is stable. It remains fine to DISPLAY
|
||||
// this as a "last updated" stamp, which is all the reports use it for.
|
||||
// then Evening on the same day. Both now bucket on `orderdate` (this
|
||||
// row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's
|
||||
// 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,
|
||||
orderstatus: mapBookingStatusToDeliveryStatus(b.status),
|
||||
droplat: b.deliverylatitude,
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
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' };
|
||||
|
||||
// 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 queryClient = useQueryClient();
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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 });
|
||||
|
||||
@@ -132,6 +176,129 @@ const AppUsers = () => {
|
||||
|
||||
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 (
|
||||
<>
|
||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||
@@ -170,24 +337,107 @@ const AppUsers = () => {
|
||||
</FilterToolbar>
|
||||
}
|
||||
>
|
||||
<TableScroll minWidth={880}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Email</TableHeaderCell>
|
||||
<TableHeaderCell>Phone</TableHeaderCell>
|
||||
<TableHeaderCell>Role</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={7} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No staff users to show"
|
||||
description="Create a staff login to give someone console access."
|
||||
@@ -196,42 +446,49 @@ const AppUsers = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.id || index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="bold">{row.first_name}</Text>
|
||||
</TableCell>
|
||||
<TableCell>{row.email}</TableCell>
|
||||
<TableCell>{row.phone || '—'}</TableCell>
|
||||
<TableCell>{roleLabel(row.role)}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<HStack gap={1} justify="end">
|
||||
<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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
</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 {
|
||||
MdLocalOffer,
|
||||
MdGroups,
|
||||
@@ -13,25 +13,46 @@ import {
|
||||
MdAdd,
|
||||
MdEdit,
|
||||
MdDeleteOutline,
|
||||
MdCalculate
|
||||
MdCalculate,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
} from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
@@ -71,6 +92,29 @@ const MetricPill = ({ 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 = {
|
||||
pricingid: null,
|
||||
tenant: '',
|
||||
@@ -100,6 +144,11 @@ const ClientsPricing = () => {
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [quoteOpen, setQuoteOpen] = useState(false);
|
||||
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({
|
||||
queryKey: ['getallpricing', appId],
|
||||
@@ -151,6 +200,12 @@ const ClientsPricing = () => {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 total = zonePricing.length;
|
||||
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 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 (
|
||||
<>
|
||||
{(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />}
|
||||
@@ -344,98 +559,181 @@ const ClientsPricing = () => {
|
||||
/>
|
||||
) : (
|
||||
<MobileCardList scroll>
|
||||
{rows.map((row, index) => (
|
||||
<MobileCard
|
||||
key={row.pricingid || `${row.tenantid}-${index}`}
|
||||
accent={BRAND}
|
||||
header={
|
||||
<HStack justify="between" vAlign="center" gap={1}>
|
||||
<HStack gap={2} vAlign="center" minWidth={0}>
|
||||
<AccentAvatar color={BRAND} size={36}>
|
||||
<MdGroups size={18} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1}>
|
||||
{tenantName(row)}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary">
|
||||
ID #{row.pricingid}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack gap={0.5}>
|
||||
<IconButton
|
||||
label="Edit rule"
|
||||
tooltip="Edit rule"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<MdEdit size={14} />}
|
||||
onClick={() => openEditDialog(row)}
|
||||
/>
|
||||
<IconButton
|
||||
label="Delete rule"
|
||||
tooltip="Delete rule"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
icon={<MdDeleteOutline size={14} />}
|
||||
onClick={() => handleDelete(row)}
|
||||
/>
|
||||
{table.getRowModel().rows.map((tableRow) => {
|
||||
const row = tableRow.original;
|
||||
return (
|
||||
<MobileCard
|
||||
key={tableRow.id}
|
||||
accent={BRAND}
|
||||
header={
|
||||
<HStack justify="between" vAlign="center" gap={1}>
|
||||
<HStack gap={2} vAlign="center" minWidth={0}>
|
||||
<AccentAvatar color={BRAND} size={36}>
|
||||
<MdGroups size={18} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1}>
|
||||
{tenantName(row)}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary">
|
||||
ID #{row.pricingid}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack gap={0.5}>
|
||||
<IconButton
|
||||
label="Edit rule"
|
||||
tooltip="Edit rule"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<MdEdit size={14} />}
|
||||
onClick={() => openEditDialog(row)}
|
||||
/>
|
||||
<IconButton
|
||||
label="Delete rule"
|
||||
tooltip="Delete rule"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
icon={<MdDeleteOutline size={14} />}
|
||||
onClick={() => handleDelete(row)}
|
||||
/>
|
||||
</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 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>
|
||||
<MobileField label="Base Price">
|
||||
<MetricPill label={formatRupees(row.baseprice)} />
|
||||
</MobileField>
|
||||
<MobileField label="Price / KM">
|
||||
<MetricPill label={formatRupees(row.priceperkm)} />
|
||||
</MobileField>
|
||||
<MobileField label="Price / KG">
|
||||
<MetricPill label={formatRupees(row.priceperkg)} />
|
||||
</MobileField>
|
||||
<MobileField label="Handling">
|
||||
<MetricPill label={formatRupees(row.handlingcharges)} />
|
||||
</MobileField>
|
||||
<MobileField label="Status">
|
||||
<HStack gap={1} vAlign="center">
|
||||
<MdReceiptLong size={14} color={DT.textMuted} />
|
||||
<Text weight="bold">{row.status || '—'}</Text>
|
||||
</HStack>
|
||||
</MobileField>
|
||||
</MobileFieldGrid>
|
||||
</MobileCard>
|
||||
))}
|
||||
<MobileFieldGrid>
|
||||
<MobileField label="Base Price">
|
||||
<MetricPill label={formatRupees(row.baseprice)} />
|
||||
</MobileField>
|
||||
<MobileField label="Price / KM">
|
||||
<MetricPill label={formatRupees(row.priceperkm)} />
|
||||
</MobileField>
|
||||
<MobileField label="Price / KG">
|
||||
<MetricPill label={formatRupees(row.priceperkg)} />
|
||||
</MobileField>
|
||||
<MobileField label="Handling">
|
||||
<MetricPill label={formatRupees(row.handlingcharges)} />
|
||||
</MobileField>
|
||||
<MobileField label="Status">
|
||||
<HStack gap={1} vAlign="center">
|
||||
<MdReceiptLong size={14} color={DT.textMuted} />
|
||||
<Text weight="bold">{row.status || '—'}</Text>
|
||||
</HStack>
|
||||
</MobileField>
|
||||
</MobileFieldGrid>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</MobileCardList>
|
||||
)
|
||||
) : (
|
||||
<TableScroll minWidth={1260}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 240 }}>Tenant</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 160 }}>Zone</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 100 }}>Vehicle</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 120 }}>Base Price</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 120 }}>Price / KM</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 120 }}>Price / KG</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 100 }}>Handling</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 100 }}>Status</TableHeaderCell>
|
||||
<TableHeaderCell style={{ minWidth: 100 }}>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={10} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No pricing to show"
|
||||
description={searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'}
|
||||
@@ -444,80 +742,19 @@ const ClientsPricing = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.pricingid || `${row.tenantid}-${index}`}>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{index + 1}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<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' }}>
|
||||
{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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
@@ -525,6 +762,30 @@ const ClientsPricing = () => {
|
||||
</Table>
|
||||
</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>
|
||||
</PageShell>
|
||||
|
||||
|
||||
@@ -24,18 +24,45 @@ import {
|
||||
MdEdit,
|
||||
MdDeleteOutline,
|
||||
MdPeopleAlt,
|
||||
MdVisibility,
|
||||
MdVisibilityOff,
|
||||
MdBlock,
|
||||
MdClose
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore,
|
||||
// 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';
|
||||
import { LuMail } from 'react-icons/lu';
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
@@ -46,6 +73,7 @@ import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { TabList, Tab } from '@astryxdesign/core/TabList';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
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 }));
|
||||
|
||||
// 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 ||============================== //
|
||||
// Self-contained — deliberately does not touch the existing openRowIndex1/2 +
|
||||
// Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT
|
||||
@@ -547,6 +596,14 @@ const Clients1 = () => {
|
||||
const [latlong, setLatlong] = useState({});
|
||||
const [editClient, setEditClient] = useState({});
|
||||
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 = () => {
|
||||
setDialogopen(false);
|
||||
@@ -822,15 +879,43 @@ const Clients1 = () => {
|
||||
|
||||
const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active;
|
||||
|
||||
const renderRowActions = (row, index, expanded, size = 'sm') => (
|
||||
<HStack gap={0.75} justify="end">
|
||||
// Icon buttons only, kept to 3 max + one overflow menu: status action,
|
||||
// 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 && (
|
||||
<IconButton
|
||||
label="Inactivate"
|
||||
tooltip="Inactivate"
|
||||
size={size}
|
||||
variant="destructive"
|
||||
icon={<MdBlock size={14} />}
|
||||
icon={<MdOutlineBlock size={17} />}
|
||||
onClick={() => {
|
||||
setSelectedCustomer(row);
|
||||
setSelectedtenid(row.tenantid);
|
||||
@@ -846,8 +931,8 @@ const Clients1 = () => {
|
||||
label="Approve"
|
||||
tooltip="Approve"
|
||||
size={size}
|
||||
variant="ghost"
|
||||
icon={<FaRegCheckCircle size={13} />}
|
||||
variant="destructive"
|
||||
icon={<MdOutlineCheckCircle size={17} />}
|
||||
onClick={() => {
|
||||
setSelectedCustomer(row);
|
||||
setDialogopen(true);
|
||||
@@ -862,8 +947,8 @@ const Clients1 = () => {
|
||||
label="Activate"
|
||||
tooltip="Activate"
|
||||
size={size}
|
||||
variant="ghost"
|
||||
icon={<FaRegCheckCircle size={13} />}
|
||||
variant="destructive"
|
||||
icon={<MdOutlineCheckCircle size={17} />}
|
||||
onClick={() => {
|
||||
setSelectedCustomer(row);
|
||||
setSelectedtenid(row.tenantid);
|
||||
@@ -879,7 +964,7 @@ const Clients1 = () => {
|
||||
tooltip={expanded ? 'Hide details' : 'View details'}
|
||||
size={size}
|
||||
variant={expanded ? 'primary' : 'ghost'}
|
||||
icon={expanded ? <MdVisibilityOff size={14} /> : <MdVisibility size={14} />}
|
||||
icon={expanded ? <MdOutlineVisibilityOff size={17} /> : <MdOutlineVisibility size={17} />}
|
||||
onClick={() => {
|
||||
setSelectedCustomer(row);
|
||||
handleCollapseToggle1(index);
|
||||
@@ -887,29 +972,13 @@ const Clients1 = () => {
|
||||
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 && (
|
||||
<IconButton
|
||||
label={openRowIndex2 === index ? 'Close' : 'Edit'}
|
||||
tooltip={openRowIndex2 === index ? 'Close' : 'Edit'}
|
||||
size={size}
|
||||
variant={openRowIndex2 === index ? 'primary' : 'ghost'}
|
||||
icon={openRowIndex2 === index ? <MdClose size={14} /> : <MdEdit size={14} />}
|
||||
icon={openRowIndex2 === index ? <MdOutlineClose size={17} /> : <MdOutlineEdit size={17} />}
|
||||
onClick={() => {
|
||||
setSelectedCustomer(row);
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -1049,6 +1134,146 @@ const Clients1 = () => {
|
||||
</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 (
|
||||
<>
|
||||
{(getalltenantsIsLoading || isloader) && <Loader />}
|
||||
@@ -1140,24 +1365,108 @@ const Clients1 = () => {
|
||||
}
|
||||
>
|
||||
{!isMobile && (
|
||||
<TableScroll minWidth={960}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Client</TableHeaderCell>
|
||||
<TableHeaderCell>Contact</TableHeaderCell>
|
||||
<TableHeaderCell>Delivery OTP</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{getalltenantsIsLoading && <TableLoader rows={5} columns={6} />}
|
||||
{getalltenantsIsLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
|
||||
{filteredTenantList.length == 0 && !isloader ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No tenants to show"
|
||||
description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
|
||||
@@ -1166,65 +1475,37 @@ const Clients1 = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredTenantList.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row, index) => {
|
||||
const rowStatusKey = String(row.status || '').toLowerCase();
|
||||
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
|
||||
const RowStatusIcon = rowStatusMeta.icon;
|
||||
table.getRowModel().rows.map((tableRow) => {
|
||||
const row = tableRow.original;
|
||||
const expanded1 = openRowIndex1 === row.tenantid;
|
||||
const expanded2 = openRowIndex2 === row.tenantid;
|
||||
return (
|
||||
<Fragment key={row.tenantid ?? index}>
|
||||
<Fragment key={tableRow.id}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{String(index + 1 + page * rowsPerPage).padStart(2, '0')}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={12} />} label={rowStatusMeta.label} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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' }}>
|
||||
{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.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>
|
||||
|
||||
{openRowIndex1 === index && (
|
||||
{expanded1 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} style={{ padding: 12 }}>
|
||||
<TableCell colSpan={colCount} style={{ padding: 12 }}>
|
||||
{renderDetailsPanel(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{openRowIndex2 === index && (
|
||||
{expanded2 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} style={{ padding: 12 }}>
|
||||
<TableCell colSpan={colCount} style={{ padding: 12 }}>
|
||||
{renderEditPanel(row)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1247,14 +1528,15 @@ const Clients1 = () => {
|
||||
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 rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
|
||||
const RowStatusIcon = rowStatusMeta.icon;
|
||||
const expanded = openRowIndex1 === index;
|
||||
const expanded = openRowIndex1 === row.tenantid;
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.tenantid ?? index}
|
||||
key={tableRow.id}
|
||||
accent={rowStatusMeta.color}
|
||||
header={
|
||||
<HStack justify="between" vAlign="start" gap={1}>
|
||||
@@ -1269,7 +1551,7 @@ const Clients1 = () => {
|
||||
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={10} />} label={rowStatusMeta.label} />
|
||||
</VStack>
|
||||
</HStack>
|
||||
{renderRowActions(row, index, expanded)}
|
||||
{renderRowActions(row, row.tenantid, expanded)}
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
@@ -1294,10 +1576,20 @@ const Clients1 = () => {
|
||||
</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 />
|
||||
<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
|
||||
page={page + 1}
|
||||
onChange={(p) => setPage(p - 1)}
|
||||
@@ -1308,7 +1600,8 @@ const Clients1 = () => {
|
||||
setRowsPerPage(size);
|
||||
setPage(0);
|
||||
}}
|
||||
variant="count"
|
||||
size="sm"
|
||||
label="Tenants pagination"
|
||||
/>
|
||||
</HStack>
|
||||
</>
|
||||
|
||||
@@ -1,10 +1,24 @@
|
||||
import { useState } from 'react';
|
||||
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
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 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 queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState('branches');
|
||||
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 [branchForm, setBranchForm] = useState(emptyBranch);
|
||||
const [carrierForm, setCarrierForm] = useState(emptyCarrier);
|
||||
@@ -86,6 +131,7 @@ const CompetitiveIntel = () => {
|
||||
const changeTab = (t) => {
|
||||
setTab(t);
|
||||
setPage(1);
|
||||
setSorting([]);
|
||||
};
|
||||
|
||||
const { data: branchesRes, isLoading: branchesLoading } = useQuery({
|
||||
@@ -224,6 +270,217 @@ const CompetitiveIntel = () => {
|
||||
const setBranch = (key) => (value) => setBranchForm((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 (
|
||||
<>
|
||||
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
|
||||
@@ -259,35 +516,107 @@ const CompetitiveIntel = () => {
|
||||
</TabList>
|
||||
}
|
||||
>
|
||||
<TableScroll minWidth={tab === 'branches' ? 920 : 780}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
{tab === 'branches' ? (
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Company</TableHeaderCell>
|
||||
<TableHeaderCell>Area</TableHeaderCell>
|
||||
<TableHeaderCell>Phone</TableHeaderCell>
|
||||
<TableHeaderCell>Address</TableHeaderCell>
|
||||
<TableHeaderCell>Rate/Kg</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
) : (
|
||||
<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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={7} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No records to show"
|
||||
description="Add a record to start benchmarking against it."
|
||||
@@ -296,49 +625,19 @@ const CompetitiveIntel = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.id || index}>
|
||||
<TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell>
|
||||
{tab === 'branches' ? (
|
||||
<>
|
||||
<TableCell>{row.company || '—'}</TableCell>
|
||||
<TableCell>{row.area || '—'}</TableCell>
|
||||
<TableCell>{row.phone || '—'}</TableCell>
|
||||
<TableCell style={{ maxWidth: 220 }}>
|
||||
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.address}>
|
||||
{row.address || '—'}
|
||||
</Text>
|
||||
</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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
@@ -350,7 +649,7 @@ const CompetitiveIntel = () => {
|
||||
<>
|
||||
<Divider />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
@@ -11,12 +11,31 @@ import {
|
||||
MdOutlineMailOutline,
|
||||
MdOutlinePhoneAndroid,
|
||||
MdPersonAddAlt1,
|
||||
MdOutlineReceiptLong
|
||||
MdOutlineReceiptLong,
|
||||
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 { Grid } from '@astryxdesign/core/Grid';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
@@ -26,6 +45,7 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
@@ -64,6 +84,29 @@ const custPhone = (row) => row.phone || '—';
|
||||
const custEmail = (row) => row.email || '—';
|
||||
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 isMobile = useIsNarrowViewport();
|
||||
const navigate = useNavigate();
|
||||
@@ -75,6 +118,9 @@ const Customers = () => {
|
||||
const [addressInput, setAddressInput] = useState('');
|
||||
const [pickAddress, setPickAddress] = useState({});
|
||||
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 });
|
||||
|
||||
@@ -158,6 +204,132 @@ const Customers = () => {
|
||||
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 (
|
||||
<>
|
||||
{isLoading && <Loader />}
|
||||
@@ -224,62 +396,150 @@ const Customers = () => {
|
||||
/>
|
||||
) : (
|
||||
<MobileCardList scroll>
|
||||
{rows.map((row, index) => (
|
||||
<MobileCard
|
||||
key={custId(row) || index}
|
||||
accent={BRAND}
|
||||
header={
|
||||
<HStack justify="between" vAlign="center" gap={1}>
|
||||
<HStack gap={2} vAlign="center" minWidth={0}>
|
||||
<AccentAvatar color={BRAND} size={36}>
|
||||
<MdPeopleAlt size={18} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1}>
|
||||
{custName(row)}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary">
|
||||
ID #{custId(row) ?? '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
{table.getRowModel().rows.map((tableRow) => {
|
||||
const row = tableRow.original;
|
||||
return (
|
||||
<MobileCard
|
||||
key={tableRow.id}
|
||||
accent={BRAND}
|
||||
header={
|
||||
<HStack justify="between" vAlign="center" gap={1}>
|
||||
<HStack gap={2} vAlign="center" minWidth={0}>
|
||||
<AccentAvatar color={BRAND} size={36}>
|
||||
<MdPeopleAlt size={18} />
|
||||
</AccentAvatar>
|
||||
<VStack gap={0} padding={0} minWidth={0}>
|
||||
<Text weight="bold" maxLines={1}>
|
||||
{custName(row)}
|
||||
</Text>
|
||||
<Text type="supporting" color="secondary">
|
||||
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>
|
||||
<IconButton
|
||||
label="Edit customer"
|
||||
tooltip="Edit customer"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
icon={<MdEdit size={14} />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<MobileFieldGrid>
|
||||
<MobileField label="Phone" value={custPhone(row)} />
|
||||
<MobileField label="Email" value={custEmail(row)} />
|
||||
<MobileField label="Bookings" value={custBookings(row)} />
|
||||
</MobileFieldGrid>
|
||||
</MobileCard>
|
||||
))}
|
||||
}
|
||||
>
|
||||
<MobileFieldGrid>
|
||||
<MobileField label="Phone" value={custPhone(row)} />
|
||||
<MobileField label="Email" value={custEmail(row)} />
|
||||
<MobileField label="Bookings" value={custBookings(row)} />
|
||||
</MobileFieldGrid>
|
||||
</MobileCard>
|
||||
);
|
||||
})}
|
||||
</MobileCardList>
|
||||
)
|
||||
) : (
|
||||
<TableScroll minWidth={720}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Customer</TableHeaderCell>
|
||||
<TableHeaderCell>Contact</TableHeaderCell>
|
||||
<TableHeaderCell>Bookings</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={5} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No customers to show"
|
||||
description="Customers appear here once they've placed an order."
|
||||
@@ -288,55 +548,19 @@ const Customers = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={custId(row) || index}>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<AccentAvatar color={BRAND} size={32}>
|
||||
<MdPeopleAlt size={16} />
|
||||
</AccentAvatar>
|
||||
<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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
@@ -344,6 +568,30 @@ const Customers = () => {
|
||||
</Table>
|
||||
</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>
|
||||
</PageShell>
|
||||
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
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 {
|
||||
useTable,
|
||||
flexRender,
|
||||
tableFeatures,
|
||||
rowSortingFeature,
|
||||
createSortedRowModel,
|
||||
sortFns,
|
||||
columnSizingFeature,
|
||||
columnResizingFeature,
|
||||
rowPaginationFeature,
|
||||
createPaginatedRowModel
|
||||
} from '@tanstack/react-table';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
import { Grid } from '@astryxdesign/core/Grid';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { TextArea } from '@astryxdesign/core/TextArea';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
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;
|
||||
|
||||
// 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_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') }));
|
||||
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
|
||||
@@ -51,6 +89,11 @@ const Exceptions = () => {
|
||||
const [resolveRow, setResolveRow] = useState(null);
|
||||
const [resolution, setResolution] = useState('');
|
||||
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: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
@@ -123,6 +166,132 @@ const Exceptions = () => {
|
||||
|
||||
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 (
|
||||
<>
|
||||
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
|
||||
@@ -163,25 +332,107 @@ const Exceptions = () => {
|
||||
</FilterToolbar>
|
||||
}
|
||||
>
|
||||
<TableScroll minWidth={1020}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Consignment</TableHeaderCell>
|
||||
<TableHeaderCell>Hub</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Severity</TableHeaderCell>
|
||||
<TableHeaderCell>Description</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={8} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No exceptions logged"
|
||||
description="Log lost, damaged or misrouted parcels here to track resolution."
|
||||
@@ -190,41 +441,49 @@ const Exceptions = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.exceptionid || index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{row.consignmentid}</TableCell>
|
||||
<TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell>
|
||||
<TableCell>{row.exceptiontype}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={SEVERITY_BADGE[row.severity] || 'neutral'} label={row.severity} />
|
||||
</TableCell>
|
||||
<TableCell style={{ maxWidth: 220 }}>
|
||||
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.description}>
|
||||
{row.description}
|
||||
</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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
</PageShell>
|
||||
|
||||
|
||||
@@ -1,11 +1,36 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace, MdOutlineCheckCircle } from 'react-icons/md';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
MdOutlineWarehouse,
|
||||
MdEdit,
|
||||
MdDeleteOutline,
|
||||
MdAdd,
|
||||
MdPlace,
|
||||
MdOutlineCheckCircle,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
} from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
@@ -16,6 +41,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
@@ -41,6 +67,29 @@ const STATUS_OPTIONS = [
|
||||
{ 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 = {
|
||||
hubid: null,
|
||||
hubname: '',
|
||||
@@ -63,6 +112,11 @@ const Hubs = () => {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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 });
|
||||
|
||||
@@ -156,6 +210,138 @@ const Hubs = () => {
|
||||
|
||||
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 (
|
||||
<>
|
||||
{(isLoading || deleteMutation.isPending) && <Loader />}
|
||||
@@ -196,25 +382,107 @@ const Hubs = () => {
|
||||
</FilterToolbar>
|
||||
}
|
||||
>
|
||||
<TableScroll minWidth={960}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Hub Name</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>City</TableHeaderCell>
|
||||
<TableHeaderCell>Address</TableHeaderCell>
|
||||
<TableHeaderCell>Pincode</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={8} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No hubs to show"
|
||||
description="Create a hub to start routing consignments through it."
|
||||
@@ -223,43 +491,49 @@ const Hubs = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.hubid || index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="bold">{row.hubname}</Text>
|
||||
</TableCell>
|
||||
<TableCell>{row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}</TableCell>
|
||||
<TableCell>{row.applocationid ?? '—'}</TableCell>
|
||||
<TableCell>{row.address || '—'}</TableCell>
|
||||
<TableCell>{row.pincode || '—'}</TableCell>
|
||||
<TableCell>{row.status ? <StatusBadge status={row.status} /> : '—'}</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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
</PageShell>
|
||||
|
||||
|
||||
@@ -2,13 +2,29 @@ import { Fragment, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
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 { 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 { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
@@ -28,7 +44,7 @@ import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider }
|
||||
import { OpenToast } from 'components/third-party/OpenToast';
|
||||
import Loader from 'components/Loader';
|
||||
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
|
||||
import { STATUS } from 'themes/dt/tokens';
|
||||
import { DT, STATUS } from 'themes/dt/tokens';
|
||||
import { TableScroll } from 'themes/dt/primitives';
|
||||
|
||||
var utc = require('dayjs/plugin/utc');
|
||||
@@ -36,125 +52,154 @@ dayjs.extend(utc);
|
||||
|
||||
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
|
||||
// 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;
|
||||
// the Zone / Rider fields are gated on aiMode just like the table columns.
|
||||
const MobileOrdersList = ({ list, aiMode }) => {
|
||||
if (!list || list.length === 0) {
|
||||
// Reads the same sorted `table` row model the desktop table does, so a
|
||||
// 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 (
|
||||
<MobileCardList scroll>
|
||||
{list.map((val, index) => (
|
||||
<MobileCard
|
||||
key={index}
|
||||
header={
|
||||
<HStack justify="between" vAlign="center" gap={1}>
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<Text weight="bold" color="secondary">
|
||||
#{index + 1}
|
||||
</Text>
|
||||
{aiMode == 1 && <Badge variant="blue" label={val.zone_name} />}
|
||||
{rows.map((tableRow, index) => {
|
||||
const val = tableRow.original;
|
||||
return (
|
||||
<MobileCard
|
||||
key={tableRow.id}
|
||||
header={
|
||||
<HStack justify="between" vAlign="center" gap={1}>
|
||||
<HStack gap={1} vAlign="center" minWidth={0}>
|
||||
<Text weight="bold" color="secondary">
|
||||
#{index + 1}
|
||||
</Text>
|
||||
{aiMode == 1 && <Badge variant="blue" label={val.zone_name} />}
|
||||
</HStack>
|
||||
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
|
||||
</HStack>
|
||||
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
|
||||
</HStack>
|
||||
}
|
||||
>
|
||||
<MobileFieldGrid>
|
||||
<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">
|
||||
}
|
||||
>
|
||||
<MobileFieldGrid>
|
||||
<MobileField label="Tenant" full>
|
||||
<Tooltip content={val.tenantaddress}>
|
||||
<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>
|
||||
<Text maxLines={1}>{val.tenantname}</Text>
|
||||
<Text type="supporting" maxLines={1}>
|
||||
{val.tenantsuburb}
|
||||
</Text>
|
||||
<Text type="supporting" maxLines={1}>
|
||||
{val.applocation}
|
||||
</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="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 label="Profit">
|
||||
<VStack gap={1} padding={0}>
|
||||
<Tooltip content="Charges">
|
||||
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
|
||||
<MobileField label="Order Location" full>
|
||||
<Tooltip content={val.locationaddress}>
|
||||
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
|
||||
</Tooltip>
|
||||
<Tooltip content="Amount">
|
||||
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} />
|
||||
<Tooltip content="Order Id">
|
||||
<Text type="supporting" maxLines={1}>
|
||||
{val.orderid}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
</MobileField>
|
||||
<HStack gap={3}>
|
||||
<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">
|
||||
<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>
|
||||
))}
|
||||
<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 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>
|
||||
);
|
||||
};
|
||||
@@ -167,6 +212,14 @@ const OrdersPreview = () => {
|
||||
const [payment, setPayment] = useState(null);
|
||||
const [finaldeliveryList, setFinalDeliveryList] = useState([]);
|
||||
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 zoneData = location.state?.zoneData;
|
||||
const metaData = location.state?.metaData;
|
||||
@@ -266,106 +319,225 @@ const OrdersPreview = () => {
|
||||
});
|
||||
};
|
||||
|
||||
const renderRow = (val, index) => (
|
||||
<Fragment key={index}>
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Text>{index + 1}</Text>
|
||||
</TableCell>
|
||||
{aiMode == 1 && (
|
||||
<TableCell>
|
||||
<Badge variant="blue" label={val.zone_name} />
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Tooltip content={val.tenantaddress}>
|
||||
// The column model — everything a column IS declared once here instead of
|
||||
// a hand-written body row, same shape as orders.js's/riders.js's/
|
||||
// Tenants.js's `columns`. Zone/Rider are pushed in conditionally, matching
|
||||
// the exact `aiMode == 1` gate the header row used before.
|
||||
const columns = useMemo(() => {
|
||||
const base = [
|
||||
{
|
||||
id: 'serial',
|
||||
size: 44,
|
||||
minSize: 36,
|
||||
maxSize: 52,
|
||||
enableSorting: false,
|
||||
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}>
|
||||
<Text maxLines={1}>{val.tenantname}</Text>
|
||||
<Text maxLines={1}>{getValue()}</Text>
|
||||
<Text type="supporting" maxLines={1}>
|
||||
{val.tenantsuburb}
|
||||
{row.original.tenantsuburb}
|
||||
</Text>
|
||||
<Text type="supporting" maxLines={1}>
|
||||
{val.applocation}
|
||||
{row.original.applocation}
|
||||
</Text>
|
||||
</VStack>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<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}>
|
||||
<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>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'orderLocation',
|
||||
accessorKey: 'locationname',
|
||||
size: 240,
|
||||
minSize: 190,
|
||||
meta: { label: 'Order Location' },
|
||||
header: () => <SectionLabel>Order Location</SectionLabel>,
|
||||
cell: ({ row }) => {
|
||||
const val = row.original;
|
||||
return (
|
||||
<>
|
||||
<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}>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'pickup',
|
||||
accessorKey: 'pickupcustomer',
|
||||
size: 200,
|
||||
minSize: 160,
|
||||
meta: { label: 'Pickup' },
|
||||
header: () => <SectionLabel>Pickup</SectionLabel>,
|
||||
cell: ({ getValue, row }) => (
|
||||
<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>
|
||||
<Text type="supporting">{getValue()}</Text>
|
||||
<Text type="supporting">{row.original.pickupcontactno}</Text>
|
||||
<Tooltip content={row.original.pickupaddress}>
|
||||
<Text type="supporting">{row.original.pickupsuburb || row.original.pickupaddress.slice(0, 20)}</Text>
|
||||
</Tooltip>
|
||||
</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}>
|
||||
<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>
|
||||
<Text type="supporting">{getValue()}</Text>
|
||||
<Text type="supporting">{row.original.deliverycontactno}</Text>
|
||||
<Tooltip content={row.original.deliveryaddress}>
|
||||
<Text type="supporting">{row.original.deliverysuburb || row.original.deliveryaddress.slice(0, 20)}</Text>
|
||||
</Tooltip>
|
||||
</VStack>
|
||||
</TableCell>
|
||||
<TableCell>{val.ordernotes}</TableCell>
|
||||
{aiMode == 1 && (
|
||||
<TableCell>
|
||||
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text>
|
||||
<Text>ID : {val.userid}</Text>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell style={{ textAlign: 'center' }}>
|
||||
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: 'center' }}>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'notes',
|
||||
accessorKey: 'ordernotes',
|
||||
size: 160,
|
||||
minSize: 120,
|
||||
meta: { label: 'Notes' },
|
||||
header: () => <SectionLabel>Notes</SectionLabel>,
|
||||
cell: ({ getValue }) => <Text>{getValue()}</Text>
|
||||
}
|
||||
);
|
||||
|
||||
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">
|
||||
<Tooltip content="Charges">
|
||||
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
|
||||
<Badge variant="error" label={`₹ ${row.original.deliverycharge.toFixed(2)} `} />
|
||||
</Tooltip>
|
||||
<Tooltip content="Amount">
|
||||
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} />
|
||||
<Badge variant="green" label={`₹ ${row.original.deliveryamt.toFixed(2)} `} />
|
||||
</Tooltip>
|
||||
</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">
|
||||
<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 content="Cumulative Kms">
|
||||
<Badge variant="green" label={`${val.cumulativekms} km`} />
|
||||
<Badge variant="green" label={`${row.original.cumulativekms} km`} />
|
||||
</Tooltip>
|
||||
</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 (
|
||||
<>
|
||||
@@ -405,34 +577,129 @@ const OrdersPreview = () => {
|
||||
|
||||
<DataCard>
|
||||
{isMobile ? (
|
||||
<MobileOrdersList list={finaldeliveryList} aiMode={aiMode} />
|
||||
<MobileOrdersList table={table} aiMode={aiMode} />
|
||||
) : (
|
||||
<TableScroll minWidth={aiMode == 1 ? 1400 : 1200}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
{aiMode == 1 && <TableHeaderCell>Zone</TableHeaderCell>}
|
||||
<TableHeaderCell>Tenant</TableHeaderCell>
|
||||
<TableHeaderCell>Order Location</TableHeaderCell>
|
||||
<TableHeaderCell>Pickup</TableHeaderCell>
|
||||
<TableHeaderCell>Delivery</TableHeaderCell>
|
||||
<TableHeaderCell>Notes</TableHeaderCell>
|
||||
{aiMode == 1 && <TableHeaderCell>Rider</TableHeaderCell>}
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Type</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Profit</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>KMS</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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 === '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>
|
||||
<TableBody>
|
||||
{finaldeliveryList?.length == 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />
|
||||
</TableCell>
|
||||
</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>
|
||||
</Table>
|
||||
</TableScroll>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Grid } from '@astryxdesign/core/Grid';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
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 { 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 { FaLocationDot } from 'react-icons/fa6';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import { PageShell } from 'components/nearle_components/PageLayout';
|
||||
import { DT } from 'themes/dt/tokens';
|
||||
import { TableScroll } from 'themes/dt/primitives';
|
||||
import './OrdersRedesign.css';
|
||||
|
||||
@@ -110,6 +126,100 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
|
||||
</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 }) => (
|
||||
<div
|
||||
role="button"
|
||||
@@ -213,6 +323,14 @@ const MultipleOrders = () => {
|
||||
const [users, setUsers] = 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({});
|
||||
|
||||
// ============================== 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 (
|
||||
<>
|
||||
{(loading || btnLoading) && <Loader />}
|
||||
@@ -1043,91 +1334,116 @@ const MultipleOrders = () => {
|
||||
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
|
||||
{previewMode === 'drops' && (
|
||||
<TableScroll minWidth={720}>
|
||||
<Table density="compact" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={dropTable.getTotalSize()}>
|
||||
<Table
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
hasHover
|
||||
style={{ tableLayout: 'fixed', width: '100%', minWidth: dropTable.getTotalSize() }}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Customer</TableHeaderCell>
|
||||
<TableHeaderCell>Address</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
|
||||
<TableHeaderCell>Km</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'right' }}>Charge</TableHeaderCell>
|
||||
<TableHeaderCell></TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{dropCust.map((customer, index) => (
|
||||
<TableRow key={customer._rowkey || index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||
{customer.firstname}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell style={{ maxWidth: 260 }}>
|
||||
{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>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: 'center' }}>
|
||||
{uploadType === 0 ? (
|
||||
customer.quantity
|
||||
) : (
|
||||
<NumberInput
|
||||
label="Quantity"
|
||||
isLabelHidden
|
||||
value={customer.quantity || undefined}
|
||||
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell style={{ textAlign: 'center' }}>
|
||||
{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"
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{customer.distance || '—'}</TableCell>
|
||||
<TableCell style={{ textAlign: 'right' }}>
|
||||
<Text weight="bold">₹{Number(customer?.totalcharge || 0).toFixed(2)}</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<IconButton
|
||||
label="Remove"
|
||||
tooltip="Remove"
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
icon={<MdDeleteOutline size={14} />}
|
||||
onClick={() => handleCheckboxChange1(customer)}
|
||||
/>
|
||||
</TableCell>
|
||||
{dropTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<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>
|
||||
<TableCell>
|
||||
<Text weight="bold">Total</Text>
|
||||
@@ -1155,39 +1471,110 @@ const MultipleOrders = () => {
|
||||
)}
|
||||
|
||||
{previewMode === 'preview' && (
|
||||
<TableScroll minWidth={640}>
|
||||
<Table density="compact" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={previewTable.getTotalSize()}>
|
||||
<Table
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
hasHover
|
||||
style={{ tableLayout: 'fixed', width: '100%', minWidth: previewTable.getTotalSize() }}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Contact</TableHeaderCell>
|
||||
<TableHeaderCell>Address</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell>
|
||||
</TableRow>
|
||||
{previewTable.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{users.map((u, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{i + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||
{u.firstname || '—'}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>{u.contactno || '—'}</TableCell>
|
||||
<TableCell style={{ maxWidth: 240 }}>
|
||||
<Tooltip content={u.address || ''}>
|
||||
<Text maxLines={1} style={{ maxWidth: 240 }}>
|
||||
{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>
|
||||
{previewTable.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>
|
||||
))}
|
||||
</TableBody>
|
||||
|
||||
@@ -63,8 +63,9 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
|
||||
import StatusTabs from 'components/nearle_components/StatusTabs';
|
||||
import TableLoader from 'components/nearle_components/TableLoader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
|
||||
@@ -909,6 +910,25 @@ const Orders = () => {
|
||||
<PageShell fill>
|
||||
<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
|
||||
fill
|
||||
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 dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -15,15 +15,34 @@ import {
|
||||
MdOutlineLocalShipping,
|
||||
MdOutlineCheckCircle,
|
||||
MdOutlinePendingActions,
|
||||
MdOutlineCancel
|
||||
MdOutlineCancel,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
} from 'react-icons/md';
|
||||
import { FaCircleCheck } from 'react-icons/fa6';
|
||||
|
||||
var utc = require('dayjs/plugin/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 { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
@@ -34,6 +53,7 @@ import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
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 ||============================== //
|
||||
|
||||
export default function OrdersDetails() {
|
||||
const isMobile = useIsNarrowViewport();
|
||||
const loadMoreRef = useRef();
|
||||
const userid = localStorage.getItem('userid');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(50);
|
||||
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
|
||||
// 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 [startdate, setStartdate] = 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 cancelLenght = statusCountByKey.cancelled;
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasNextPage) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[0].isIntersecting) {
|
||||
fetchNextPage();
|
||||
}
|
||||
},
|
||||
{
|
||||
// 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]);
|
||||
// The scroll-to-reveal sentinel + IntersectionObserver that used to live
|
||||
// here was already dead: the unconditional drain effect above (`if
|
||||
// (hasNextPage && !isFetchingNextPage) fetchNextPage()`) exhausts every
|
||||
// page as soon as data arrives, regardless of scroll position, so
|
||||
// `hasNextPage` is almost always already false by the time a user could
|
||||
// scroll to the sentinel. Real client-side pagination (via the `table`
|
||||
// instance below) replaces it instead.
|
||||
|
||||
// ==============================|| Tenant / Location / Rider lookups ||============================== //
|
||||
|
||||
@@ -457,6 +483,349 @@ export default function OrdersDetails() {
|
||||
{ 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 (
|
||||
<>
|
||||
{(fetchDeliveriesIsLoading ||
|
||||
@@ -470,7 +839,7 @@ export default function OrdersDetails() {
|
||||
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
|
||||
left that box half empty. */}
|
||||
<PageShell>
|
||||
<PageShell fill>
|
||||
{/* 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
|
||||
button — LocationAutocomplete brings its own width and ignores the
|
||||
@@ -481,17 +850,7 @@ export default function OrdersDetails() {
|
||||
title="Orders Details"
|
||||
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
|
||||
live
|
||||
action={
|
||||
<Button
|
||||
label="Export"
|
||||
variant="primary"
|
||||
icon={<MdFileDownload size={16} />}
|
||||
onClick={() => {
|
||||
setReportDialog(true);
|
||||
setTimeout(() => setRowsPerPage(''), 0);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
action={<Button label="Export" variant="primary" icon={<MdFileDownload size={16} />} onClick={() => setReportDialog(true)} />}
|
||||
/>
|
||||
|
||||
<KpiGrid>
|
||||
@@ -512,13 +871,14 @@ export default function OrdersDetails() {
|
||||
|
||||
{/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */}
|
||||
<DataCard
|
||||
fill
|
||||
toolbar={
|
||||
<FilterToolbar max={5} minWidth={200}>
|
||||
<LocationAutocomplete
|
||||
locaName={locaName}
|
||||
setAppId={setAppId}
|
||||
setLocoName={setLocoName}
|
||||
setPage={setPage}
|
||||
setPage={() => setPagination((p) => ({ ...p, pageIndex: 0 }))}
|
||||
placeholder="Select Zone"
|
||||
/>
|
||||
|
||||
@@ -639,19 +999,23 @@ export default function OrdersDetails() {
|
||||
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 isDelivered = row.orderstatus === 'delivered';
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.deliveryid || `${row.orderid}-${index}`}
|
||||
key={tableRow.id}
|
||||
accent={rowStatusMeta.color}
|
||||
header={
|
||||
<VStack gap={1.5} padding={0}>
|
||||
<HStack justify="between" vAlign="start" gap={1}>
|
||||
<HStack gap={1.5} vAlign="center" minWidth={0}>
|
||||
<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>
|
||||
<AccentAvatar color={BRAND} size={32}>
|
||||
<MdGroups size={16} />
|
||||
@@ -802,248 +1166,168 @@ export default function OrdersDetails() {
|
||||
)}
|
||||
</MobileCardList>
|
||||
) : (
|
||||
<TableScroll minWidth={1600}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Map</TableHeaderCell>
|
||||
<TableHeaderCell>Client</TableHeaderCell>
|
||||
<TableHeaderCell>Pickup</TableHeaderCell>
|
||||
<TableHeaderCell>Drop</TableHeaderCell>
|
||||
<TableHeaderCell>Status / Rider</TableHeaderCell>
|
||||
<TableHeaderCell>Assigned</TableHeaderCell>
|
||||
<TableHeaderCell>Accepted</TableHeaderCell>
|
||||
<TableHeaderCell>Arrived</TableHeaderCell>
|
||||
<TableHeaderCell>Picked</TableHeaderCell>
|
||||
<TableHeaderCell>Active</TableHeaderCell>
|
||||
<TableHeaderCell>Delivered</TableHeaderCell>
|
||||
<TableHeaderCell>Cancelled</TableHeaderCell>
|
||||
<TableHeaderCell>Notes</TableHeaderCell>
|
||||
<TableHeaderCell>KMS</TableHeaderCell>
|
||||
<TableHeaderCell>Charges</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{fetchDeliveriesIsLoading ? (
|
||||
<TableLoader rows={16} columns={16} />
|
||||
) : rows?.length == 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={16}>
|
||||
<EmptyState
|
||||
title="No orders to show"
|
||||
description={searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'}
|
||||
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);
|
||||
}
|
||||
<VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
|
||||
<TableScroll minWidth={table.getTotalSize()} maxHeight="100%">
|
||||
{/* 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 Details"
|
||||
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
|
||||
>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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'
|
||||
}}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell style={{ verticalAlign: 'top' }}>
|
||||
<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' }}>
|
||||
{row.tenantname}
|
||||
</Text>
|
||||
<Tooltip content="Order ID">
|
||||
<Text type="supporting" color="secondary" weight="bold">
|
||||
#{row.orderid}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
<Text type="supporting" color="secondary">
|
||||
{parseDoormileTimestamp(row.deliverydate).format('DD/MM/YYYY · hh:mm A')}
|
||||
</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
</TableCell>
|
||||
|
||||
<TableCell style={{ verticalAlign: 'top' }}>
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
|
||||
{row.pickupcustomer || '—'}
|
||||
</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
|
||||
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>
|
||||
) : (
|
||||
<Text type="supporting" color="secondary">
|
||||
—
|
||||
</Text>
|
||||
)}
|
||||
</TableCell>
|
||||
|
||||
<TableCell style={{ verticalAlign: 'top', textAlign: 'center' }}>
|
||||
<VStack gap={1} padding={0} vAlign="center">
|
||||
<MetricPill
|
||||
variant="red"
|
||||
icon={<MdStraighten size={11} />}
|
||||
label={cancelled || row.kms == '' ? '0.00 km' : `${formatKm(row.kms)} km`}
|
||||
tooltip="KMS"
|
||||
/>
|
||||
<MetricPill
|
||||
variant="green"
|
||||
icon={<MdStraighten size={11} />}
|
||||
label={`${formatKm(row.cumulativekms)} km`}
|
||||
tooltip="Actual KMS"
|
||||
/>
|
||||
<MetricPill
|
||||
variant="blue"
|
||||
icon={<MdStraighten size={11} />}
|
||||
label={`${formatKm(row.previouskms || (cancelled ? 0 : row.kms))} km`}
|
||||
tooltip="Rider KMS"
|
||||
/>
|
||||
</VStack>
|
||||
</TableCell>
|
||||
{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>
|
||||
|
||||
<TableCell style={{ verticalAlign: 'top', textAlign: 'center' }}>
|
||||
<VStack gap={1} padding={0} vAlign="center">
|
||||
<MetricPill
|
||||
variant="red"
|
||||
icon={<MdCurrencyRupee size={11} />}
|
||||
label={cancelled || row.deliverycharges == '' ? `0.00` : `${row.deliverycharges}.00`}
|
||||
tooltip="Delivery Charge"
|
||||
/>
|
||||
<MetricPill
|
||||
variant="green"
|
||||
icon={<MdCurrencyRupee size={11} />}
|
||||
label={row.deliveryamt == '' ? `0.00` : `${row.deliveryamt}.00`}
|
||||
tooltip="Delivery Amount"
|
||||
/>
|
||||
</VStack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableScroll>
|
||||
<TableBody>
|
||||
{fetchDeliveriesIsLoading ? (
|
||||
<TableLoader rows={16} columns={colCount} />
|
||||
) : rows?.length == 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No orders to show"
|
||||
description={searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'}
|
||||
icon={<MdAssignment size={28} />}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
table.getRowModel().rows.map((tableRow) => {
|
||||
return (
|
||||
<TableRow key={tableRow.id}>
|
||||
{tableRow.getAllCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
style={{
|
||||
verticalAlign: 'top',
|
||||
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>
|
||||
)}
|
||||
|
||||
{rows?.length !== 0 && (
|
||||
{/* Pagination replaces the old scroll-to-reveal sentinel — the
|
||||
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 />
|
||||
<VStack vAlign="center" padding={3} ref={loadMoreRef}>
|
||||
{isFetchingNextPage || hasNextPage ? (
|
||||
<LoaderWithImage />
|
||||
) : (
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
No more orders
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
<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} order${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="Orders details pagination"
|
||||
/>
|
||||
</HStack>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -19,12 +19,34 @@ import {
|
||||
MdOutlineLocalShipping,
|
||||
MdOutlinePendingActions,
|
||||
MdOutlineCheckCircle,
|
||||
MdOutlineCurrencyRupee
|
||||
MdOutlineCurrencyRupee,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
} from 'react-icons/md';
|
||||
|
||||
var utc = require('dayjs/plugin/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 { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
@@ -36,6 +58,7 @@ import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import { getreportlocationsummary, getreportsummary, gettenantlocations, getTenants } from 'pages/api/api';
|
||||
@@ -124,6 +147,31 @@ function formatNumberToRupees(value) {
|
||||
}).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
|
||||
// ============================================================================
|
||||
@@ -144,6 +192,9 @@ export default function OrdersReport() {
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
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.
|
||||
useEffect(() => {
|
||||
@@ -360,6 +411,242 @@ export default function OrdersReport() {
|
||||
</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 (
|
||||
<>
|
||||
{(loading || isLoadingReports) && <Loader />}
|
||||
@@ -400,9 +687,9 @@ export default function OrdersReport() {
|
||||
not a lifecycle list), so the toolbar just holds every filter
|
||||
including search — same shape as hubs.js/vehicles.js when there's
|
||||
nothing to pair search with. The two-row grouped Orders/Deliveries
|
||||
header below (rowSpan/colSpan) stays a hand-rolled table rather
|
||||
than a TanStack Table v9 column-group conversion — a materially
|
||||
larger, riskier change than this pass's chrome scope. */}
|
||||
header is a TanStack Table v9 column-group (see the `columns`
|
||||
useMemo above) — table-core computes rowSpan/colSpan from the
|
||||
group structure, the render loop below just follows it. */}
|
||||
<DataCard
|
||||
toolbar={
|
||||
<FilterToolbar max={4} minWidth={200}>
|
||||
@@ -486,7 +773,11 @@ export default function OrdersReport() {
|
||||
|
||||
{!isLoadingReports && filteredRows.length > 0 && (
|
||||
<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 isOpen = openRow === rowKey;
|
||||
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
|
||||
@@ -494,7 +785,7 @@ export default function OrdersReport() {
|
||||
|
||||
return (
|
||||
<MobileCard
|
||||
key={`${rowKey}-${index}`}
|
||||
key={tableRow.id}
|
||||
accent={rowAccent}
|
||||
selected={isOpen}
|
||||
header={
|
||||
@@ -592,59 +883,78 @@ export default function OrdersReport() {
|
||||
</>
|
||||
) : (
|
||||
<TableScroll minWidth={1200}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<Table density="balanced" dividers="rows" hasHover style={{ width: '100%' }} aria-label="Orders Summary">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell rowSpan={2}>#</TableHeaderCell>
|
||||
<TableHeaderCell rowSpan={2}>{isLocationGroup ? 'Location' : 'Tenant'}</TableHeaderCell>
|
||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
||||
All
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell colSpan={3} style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}>
|
||||
Orders
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell colSpan={3} style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}>
|
||||
Deliveries
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
||||
Collection
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
||||
KMS / Actual
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}>
|
||||
Amount
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell rowSpan={2}>Action</TableHeaderCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#f59e0b' }}>
|
||||
Pending
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}>
|
||||
Cancelled
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#10b981' }}>
|
||||
Completed
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#f59e0b' }}>
|
||||
Pending
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#ef4444' }}>
|
||||
Cancelled
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}>
|
||||
Completed
|
||||
</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
// Placeholder chain: a leaf column shallower than the
|
||||
// table's max depth gets padded with placeholders;
|
||||
// every header covering the same visual cell after
|
||||
// the first reports rowSpan 0 and must be skipped —
|
||||
// rendering it would draw an extra empty <th>.
|
||||
if (header.rowSpan === 0) return null;
|
||||
const meta = header.column.columnDef.meta;
|
||||
const align = meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
return (
|
||||
<TableHeaderCell
|
||||
key={header.id}
|
||||
scope="col"
|
||||
rowSpan={header.rowSpan}
|
||||
colSpan={header.colSpan}
|
||||
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
|
||||
style={{
|
||||
textAlign: align,
|
||||
backgroundColor: meta?.headerBg,
|
||||
color: meta?.headerColor
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<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} style={{ flexShrink: 0 }} />
|
||||
) : sorted === 'desc' ? (
|
||||
<MdArrowDownward size={12} style={{ flexShrink: 0 }} />
|
||||
) : (
|
||||
<MdUnfoldMore size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
|
||||
))}
|
||||
</HStack>
|
||||
)}
|
||||
</TableHeaderCell>
|
||||
);
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{isLoadingReports && <TableLoader rows={5} columns={13} />}
|
||||
{isLoadingReports && <TableLoader rows={5} columns={colCount} />}
|
||||
|
||||
{!isLoadingReports && filteredRows.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title={`No ${isLocationGroup ? 'locations' : 'tenants'} to show`}
|
||||
description={
|
||||
@@ -660,111 +970,24 @@ export default function OrdersReport() {
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{filteredRows.map((row, index) => {
|
||||
{table.getRowModel().rows.map((tableRow) => {
|
||||
const row = tableRow.original;
|
||||
const rowKey = isLocationGroup ? row.locationname : row.tenantname;
|
||||
const isOpen = openRow === rowKey;
|
||||
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
|
||||
|
||||
return (
|
||||
<React.Fragment key={`${rowKey}-${index}`}>
|
||||
<React.Fragment key={tableRow.id}>
|
||||
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Text>
|
||||
</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.getAllCells().map((cell) => (
|
||||
<TableCell key={cell.id} style={{ textAlign: cell.column.columnDef.meta?.align }}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
{isOpen && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={13} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
||||
<TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
||||
<div style={{ padding: 12 }}>
|
||||
<RiderBreakdown label={isLocationGroup ? row.locationname : row.tenantname} />
|
||||
</div>
|
||||
@@ -779,8 +1002,31 @@ export default function OrdersReport() {
|
||||
</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 && (
|
||||
<>
|
||||
<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 />
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" padding={2} style={{ background: '#ffffff' }}>
|
||||
<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 dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -17,15 +17,34 @@ import {
|
||||
MdPerson,
|
||||
MdOutlineLocalShipping,
|
||||
MdOutlineCheckCircle,
|
||||
MdOutlineCurrencyRupee
|
||||
MdOutlineCurrencyRupee,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore
|
||||
} from 'react-icons/md';
|
||||
|
||||
var utc = require('dayjs/plugin/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 { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
@@ -33,6 +52,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Dialog } from '@astryxdesign/core/Dialog';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import { fetchRidersSummary } from 'pages/api/api';
|
||||
@@ -121,6 +141,29 @@ function formatNumberToRupees(value) {
|
||||
}).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 ||============================== //
|
||||
|
||||
export default function RidersSummary() {
|
||||
@@ -141,6 +184,11 @@ export default function RidersSummary() {
|
||||
const [routeLoading, setRouteLoading] = useState(false);
|
||||
const [searchword, setSearchword] = 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 ||============================== //
|
||||
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)));
|
||||
}, [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.
|
||||
const stats = useMemo(() => {
|
||||
if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 };
|
||||
@@ -287,6 +341,169 @@ export default function RidersSummary() {
|
||||
</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 (
|
||||
<>
|
||||
{(isLoadingReports || loading) && <Loader />}
|
||||
@@ -350,13 +567,17 @@ export default function RidersSummary() {
|
||||
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 amount = Number(row.ridercharges) || 0;
|
||||
const riderName = row?.displayname || '—';
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.userid || index}
|
||||
key={tableRow.id}
|
||||
accent={BRAND}
|
||||
selected={isOpen}
|
||||
header={
|
||||
@@ -370,7 +591,7 @@ export default function RidersSummary() {
|
||||
{riderName}
|
||||
</Text>
|
||||
<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>
|
||||
</VStack>
|
||||
</HStack>
|
||||
@@ -434,27 +655,108 @@ export default function RidersSummary() {
|
||||
)}
|
||||
</MobileCardList>
|
||||
) : (
|
||||
<TableScroll minWidth={1080}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Rider</TableHeaderCell>
|
||||
<TableHeaderCell>Orders</TableHeaderCell>
|
||||
<TableHeaderCell>Accepted</TableHeaderCell>
|
||||
<TableHeaderCell>Cancelled</TableHeaderCell>
|
||||
<TableHeaderCell>Delivered</TableHeaderCell>
|
||||
<TableHeaderCell>KMS</TableHeaderCell>
|
||||
<TableHeaderCell>Amount</TableHeaderCell>
|
||||
<TableHeaderCell>Action</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
|
||||
<TableBody>
|
||||
{isLoadingReports && <TableLoader rows={5} columns={9} />}
|
||||
{isLoadingReports && <TableLoader rows={5} columns={colCount} />}
|
||||
{(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No riders to show"
|
||||
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>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredRows.map((row, index) => {
|
||||
table.getRowModel().rows.map((tableRow) => {
|
||||
const row = tableRow.original;
|
||||
const isOpen = openRow === row.userid;
|
||||
const amount = Number(row.ridercharges) || 0;
|
||||
return (
|
||||
<React.Fragment key={row.userid || index}>
|
||||
<React.Fragment key={tableRow.id}>
|
||||
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Text>
|
||||
</TableCell>
|
||||
|
||||
<TableCell>
|
||||
<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' }}>
|
||||
{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.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>
|
||||
|
||||
{isOpen && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
||||
<TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
|
||||
<div style={{ padding: 12 }}>
|
||||
<TenantBreakdown label={row?.displayname || ''} />
|
||||
</div>
|
||||
@@ -563,8 +802,31 @@ export default function RidersSummary() {
|
||||
</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 && (
|
||||
<>
|
||||
<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 />
|
||||
<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' }}>
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
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 { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { DateInput } from '@astryxdesign/core/DateInput';
|
||||
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
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;
|
||||
|
||||
// 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 = {
|
||||
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
|
||||
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
|
||||
@@ -55,6 +105,11 @@ export default function RiderSubstitution({
|
||||
const isMobile = useIsNarrowViewport();
|
||||
const [selectedBatch, setSelectedBatch] = useState('all');
|
||||
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) => {
|
||||
if (!timeStr) return 'unknown';
|
||||
@@ -136,6 +191,12 @@ export default function RiderSubstitution({
|
||||
return (rows || []).filter((row) => getShiftBatch(row.starttime) === 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);
|
||||
|
||||
// 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 = (
|
||||
<HStack justify="between" vAlign="center" gap={3} 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} />} />
|
||||
) : (
|
||||
<MobileCardList scroll>
|
||||
{filteredRows?.map((row, index) => {
|
||||
{table.getRowModel().rows.map((tableRow, index) => {
|
||||
const row = tableRow.original;
|
||||
const statusMeta = getRowStatusMeta(row);
|
||||
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
|
||||
const starttime = riderProfile?.starttime || row.starttime;
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.userid ?? index}
|
||||
key={tableRow.id ?? index}
|
||||
accent={statusMeta.color}
|
||||
header={
|
||||
<VStack gap={2} padding={0}>
|
||||
@@ -265,89 +454,160 @@ export default function RiderSubstitution({
|
||||
</MobileCardList>
|
||||
)
|
||||
) : (
|
||||
<TableScroll minWidth={1200}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Rider</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell>
|
||||
<TableHeaderCell>Substitute Rider</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{filteredRows?.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
{filteredRows?.length !== 0 &&
|
||||
filteredRows?.map((row, index) => {
|
||||
const statusMeta = getRowStatusMeta(row);
|
||||
const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
|
||||
const starttime = riderProfile?.starttime || row.starttime;
|
||||
return (
|
||||
<TableRow key={row.userid ?? index}>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Text>
|
||||
table.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>
|
||||
<TableCell>
|
||||
<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
|
||||
}}
|
||||
>
|
||||
#{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>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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 && (
|
||||
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
|
||||
<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';
|
||||
var utc = require('dayjs/plugin/utc');
|
||||
import dayjs from 'dayjs';
|
||||
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 {
|
||||
MdCheckCircle,
|
||||
MdCancel,
|
||||
@@ -14,6 +28,9 @@ import {
|
||||
MdEdit,
|
||||
MdKeyboardArrowDown,
|
||||
MdKeyboardArrowUp,
|
||||
MdArrowUpward,
|
||||
MdArrowDownward,
|
||||
MdUnfoldMore,
|
||||
MdLocationOn,
|
||||
MdBatteryStd,
|
||||
MdPowerSettingsNew,
|
||||
@@ -32,6 +49,7 @@ import { Grid } from '@astryxdesign/core/Grid';
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Center } from '@astryxdesign/core/Center';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
import { IconButton } from '@astryxdesign/core/IconButton';
|
||||
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 { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
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 }
|
||||
];
|
||||
|
||||
// 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
|
||||
// pattern from the pricing page so the telemetry block reads at a glance.
|
||||
const LogChip = ({ color, icon: Icon, label, value }) => (
|
||||
@@ -182,15 +225,17 @@ const Riders = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const isMobile = useIsNarrowViewport();
|
||||
const loadMoreRef = useRef();
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [locaName, setLocoName] = useState('All');
|
||||
const [appId, setAppId] = useState(0);
|
||||
const [tabvalue, setTabvalue] = useState(0);
|
||||
const roleid = localStorage.getItem('roleid');
|
||||
const totalCols = roleid == 1 ? 11 : 10;
|
||||
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 [historyDate, setHistoryDate] = useState(dayjs());
|
||||
const [historyStatus, setHistoryStatus] = useState('all');
|
||||
@@ -320,62 +365,55 @@ const Riders = () => {
|
||||
};
|
||||
|
||||
// ==============================|| fetchAllRiders||============================== //
|
||||
// GET /admin/milers isn't paginated (fetchAllRiders always returns the full
|
||||
// list in one shot) — getNextPageParam must not request a "next page" or it
|
||||
// re-fetches and appends the identical full list again, duplicating every
|
||||
// rider in `rows`.
|
||||
const {
|
||||
data: allRidersData,
|
||||
isLoading: allRidersLoading,
|
||||
isFetchingNextPage,
|
||||
fetchNextPage,
|
||||
hasNextPage
|
||||
} = useInfiniteQuery({
|
||||
// GET /admin/milers isn't paginated — fetchAllRiders always returns the
|
||||
// full list in one shot (`{ details, nextPage: undefined }`). Previously
|
||||
// wrapped in useInfiniteQuery with a getNextPageParam that always returned
|
||||
// undefined, so "loading more" never actually fetched a second page — a
|
||||
// plain bulk fetch, same shape orders.js uses for bookings, with the table
|
||||
// below paginating the already-complete list client-side.
|
||||
const { data: allRidersResult, isLoading: allRidersLoading } = useQuery({
|
||||
queryKey: ['allriders', appId, debouncedSearch, tabvalue],
|
||||
queryFn: fetchAllRiders,
|
||||
getNextPageParam: () => undefined
|
||||
queryFn: fetchAllRiders
|
||||
});
|
||||
|
||||
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
|
||||
// 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
|
||||
// no-ops until filtered client-side here.
|
||||
const filteredRows = rows.filter((row) => {
|
||||
// "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)
|
||||
);
|
||||
});
|
||||
//
|
||||
// Memoized (unlike the pre-TanStack version of this file) because the table
|
||||
// reads this as `data`: TanStack memoizes its core row model on the `data`
|
||||
// REFERENCE and auto-resets `pageIndex` to 0 whenever that reference
|
||||
// changes. An unmemoized `.filter()` returns a new array every render —
|
||||
// including the very render a page-2/3/4 click causes — so pagination
|
||||
// looked broken (every click snapped straight back to page 1). orders.js's
|
||||
// `filteredRows` was already memoized for the same reason.
|
||||
const filteredRows = useMemo(
|
||||
() =>
|
||||
rows.filter((row) => {
|
||||
// "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(() => {
|
||||
if (!hasNextPage) return;
|
||||
const observer = new IntersectionObserver(
|
||||
(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]);
|
||||
setPagination((p) => ({ ...p, pageIndex: 0 }));
|
||||
}, [tabvalue, debouncedSearch]);
|
||||
|
||||
// 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).
|
||||
@@ -501,6 +539,222 @@ const Riders = () => {
|
||||
</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 (
|
||||
<>
|
||||
{(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && (
|
||||
@@ -532,19 +786,12 @@ const Riders = () => {
|
||||
</KpiGrid>
|
||||
|
||||
<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={
|
||||
<FilterToolbar max={tabvalue === 3 ? 3 : 1}>
|
||||
<HStack justify="end">
|
||||
<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 ? (
|
||||
<FilterToolbar max={2}>
|
||||
<HStack gap={2} vAlign="center">
|
||||
<Text weight="bold" color="secondary">
|
||||
Status:
|
||||
@@ -555,8 +802,6 @@ const Riders = () => {
|
||||
))}
|
||||
</SegmentedControl>
|
||||
</HStack>
|
||||
)}
|
||||
{tabvalue === 3 && (
|
||||
<DateInput
|
||||
label="Select Date"
|
||||
isLabelHidden
|
||||
@@ -564,30 +809,45 @@ const Riders = () => {
|
||||
value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined}
|
||||
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={
|
||||
<TabList value={String(tabvalue)} onChange={(v) => handleChangetab(Number(v))} size="sm">
|
||||
{TAB_META.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const count =
|
||||
t.key === 2
|
||||
? subRidersLogsData?.length || 0
|
||||
: t.key === 3
|
||||
? subHistoryData?.length || 0
|
||||
: allRidersSummary?.[t.countKey] ?? 0;
|
||||
return (
|
||||
<Tab
|
||||
key={t.key}
|
||||
value={String(t.key)}
|
||||
label={t.label}
|
||||
icon={<Icon size={15} />}
|
||||
endContent={<Badge variant="neutral" label={count} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TabList>
|
||||
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
|
||||
<TabList value={String(tabvalue)} onChange={(v) => handleChangetab(Number(v))} size="sm">
|
||||
{TAB_META.map((t) => {
|
||||
const Icon = t.icon;
|
||||
const count =
|
||||
t.key === 2
|
||||
? subRidersLogsData?.length || 0
|
||||
: t.key === 3
|
||||
? subHistoryData?.length || 0
|
||||
: allRidersSummary?.[t.countKey] ?? 0;
|
||||
return (
|
||||
<Tab
|
||||
key={t.key}
|
||||
value={String(t.key)}
|
||||
label={t.label}
|
||||
icon={<Icon size={15} />}
|
||||
endContent={<Badge variant="neutral" label={count} />}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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 ? (
|
||||
@@ -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?.map((row, index) => {
|
||||
table.getRowModel().rows.map((tableRow, index) => {
|
||||
const row = tableRow.original;
|
||||
const statusMeta = getRowStatusMeta(row);
|
||||
const expanded = logsRow === row.milerprofileid;
|
||||
return (
|
||||
<MobileCard
|
||||
key={row.userid ?? index}
|
||||
key={tableRow.id ?? index}
|
||||
accent={statusMeta.color}
|
||||
selected={expanded}
|
||||
header={
|
||||
@@ -859,43 +1123,110 @@ const Riders = () => {
|
||||
</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>
|
||||
) : (
|
||||
<TableScroll minWidth={1300}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Rider</TableHeaderCell>
|
||||
<TableHeaderCell>Address</TableHeaderCell>
|
||||
<TableHeaderCell>Vehicle</TableHeaderCell>
|
||||
<TableHeaderCell>Shift</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Time</TableHeaderCell>
|
||||
<TableHeaderCell>Fare</TableHeaderCell>
|
||||
<TableHeaderCell>Fuel</TableHeaderCell>
|
||||
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell>
|
||||
{roleid == 1 && <TableHeaderCell>Action</TableHeaderCell>}
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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 === '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>
|
||||
<TableBody>
|
||||
{allRidersLoading && <TableLoader rows={6} columns={totalCols} />}
|
||||
{allRidersLoading && <TableLoader rows={6} columns={colCount} />}
|
||||
|
||||
{filteredRows?.length === 0 && !allRidersLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={totalCols}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No riders to show"
|
||||
description={
|
||||
@@ -908,110 +1239,32 @@ const Riders = () => {
|
||||
)}
|
||||
|
||||
{filteredRows?.length !== 0 &&
|
||||
filteredRows?.map((row, index) => {
|
||||
const statusMeta = getRowStatusMeta(row);
|
||||
table.getRowModel().rows.map((tableRow) => {
|
||||
const row = tableRow.original;
|
||||
const expanded = logsRow === row.milerprofileid;
|
||||
return (
|
||||
<Fragment key={row.userid ?? index}>
|
||||
<Fragment key={tableRow.id}>
|
||||
<TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}>
|
||||
<TableCell>
|
||||
<Text type="supporting" weight="bold" color="secondary">
|
||||
{String(index + 1).padStart(2, '0')}
|
||||
</Text>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div
|
||||
{/* getAllCells (core), not getVisibleCells —
|
||||
that one is gated behind
|
||||
columnVisibilityFeature, which this table
|
||||
doesn't register. */}
|
||||
{tableRow.getAllCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
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
|
||||
textAlign: cell.column.columnDef.meta?.align,
|
||||
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
|
||||
}}
|
||||
>
|
||||
#{row?.userid}
|
||||
</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} />
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
)}
|
||||
))}
|
||||
</TableRow>
|
||||
|
||||
{expanded && tabvalue !== 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={totalCols} style={{ padding: 12 }}>
|
||||
<TableCell colSpan={colCount} style={{ padding: 12 }}>
|
||||
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
@@ -1019,26 +1272,39 @@ const Riders = () => {
|
||||
</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>
|
||||
</Table>
|
||||
</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>
|
||||
|
||||
@@ -1,17 +1,44 @@
|
||||
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';
|
||||
// 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 { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
@@ -43,12 +70,38 @@ const BRAND = DT.brand;
|
||||
|
||||
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 queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [itemDialogTrip, setItemDialogTrip] = useState(null);
|
||||
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: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
@@ -132,6 +185,115 @@ const Tripsheets = () => {
|
||||
|
||||
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 (
|
||||
<>
|
||||
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
|
||||
@@ -150,22 +312,107 @@ const Tripsheets = () => {
|
||||
</KpiGrid>
|
||||
|
||||
<DataCard>
|
||||
<TableScroll minWidth={920}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Source Hub</TableHeaderCell>
|
||||
<TableHeaderCell>Destination Hub</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={5} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{tripsheets.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState
|
||||
title="No tripsheets to show"
|
||||
description="Create a tripsheet to move consignments between hubs."
|
||||
@@ -174,48 +421,49 @@ const Tripsheets = () => {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tripsheets.map((row, index) => (
|
||||
<TableRow key={row.tripsheetid || index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
|
||||
<TableCell>
|
||||
{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}
|
||||
</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell>
|
||||
<HStack gap={1} justify="end">
|
||||
<IconButton
|
||||
label="Add item"
|
||||
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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
</PageShell>
|
||||
|
||||
|
||||
@@ -1,17 +1,46 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd, MdBatteryFull } from 'react-icons/md';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
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 { HStack } from '@astryxdesign/core/HStack';
|
||||
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 { IconButton } from '@astryxdesign/core/IconButton';
|
||||
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
||||
import { Divider } from '@astryxdesign/core/Divider';
|
||||
import { TextInput } from '@astryxdesign/core/TextInput';
|
||||
import { NumberInput } from '@astryxdesign/core/NumberInput';
|
||||
import { Selector } from '@astryxdesign/core/Selector';
|
||||
import { EmptyState } from '@astryxdesign/core/EmptyState';
|
||||
import { Pagination } from '@astryxdesign/core/Pagination';
|
||||
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
@@ -29,6 +58,29 @@ import { TableScroll } from 'themes/dt/primitives';
|
||||
const BRAND = DT.brand;
|
||||
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 = {
|
||||
vehicleid: null,
|
||||
vehicleno: '',
|
||||
@@ -51,6 +103,11 @@ const Vehicles = () => {
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
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: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
|
||||
@@ -128,6 +185,12 @@ const Vehicles = () => {
|
||||
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 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 (
|
||||
<>
|
||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||
@@ -188,68 +381,154 @@ const Vehicles = () => {
|
||||
</FilterToolbar>
|
||||
}
|
||||
>
|
||||
<TableScroll minWidth={960}>
|
||||
<Table density="balanced" dividers="rows" hasHover>
|
||||
<TableScroll minWidth={table.getTotalSize()}>
|
||||
{/* 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>
|
||||
<TableRow>
|
||||
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
|
||||
<TableHeaderCell>Vehicle No</TableHeaderCell>
|
||||
<TableHeaderCell>Type</TableHeaderCell>
|
||||
<TableHeaderCell>Partner</TableHeaderCell>
|
||||
<TableHeaderCell>Max Weight</TableHeaderCell>
|
||||
<TableHeaderCell>Battery</TableHeaderCell>
|
||||
<TableHeaderCell>Status</TableHeaderCell>
|
||||
<TableHeaderCell>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} isHeaderRow>
|
||||
{headerGroup.headers.map((header) => {
|
||||
const align = header.column.columnDef.meta?.align;
|
||||
const canSort = header.column.getCanSort();
|
||||
const sorted = header.column.getIsSorted();
|
||||
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>
|
||||
<TableBody>
|
||||
{isLoading && <TableLoader rows={5} columns={8} />}
|
||||
{isLoading && <TableLoader rows={5} columns={colCount} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8}>
|
||||
<TableCell colSpan={colCount}>
|
||||
<EmptyState title="No vehicles to show" icon={<MdDirectionsCar size={28} />} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.vehicleid || index}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Text weight="bold">{row.vehicleno}</Text>
|
||||
</TableCell>
|
||||
<TableCell>{row.vehicletype || '—'}</TableCell>
|
||||
<TableCell>
|
||||
{partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}
|
||||
</TableCell>
|
||||
<TableCell>{row.maxweight ?? '—'}</TableCell>
|
||||
<TableCell>{row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}</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>
|
||||
table.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>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
</PageShell>
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ const AppUsers = Loadable(lazy(() => import('pages/nearle/appUsers/appUsers')));
|
||||
const Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
|
||||
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
|
||||
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
|
||||
const Assistant = Loadable(lazy(() => import('pages/nearle/assistant/assistant')));
|
||||
|
||||
// ==============================|| MAIN ROUTING ||============================== //
|
||||
|
||||
@@ -168,6 +169,10 @@ const MainRoutes = {
|
||||
{
|
||||
path: 'competitive-intel',
|
||||
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",
|
||||
"dispatch": "Dispatch",
|
||||
"customers": "Customers",
|
||||
"assistant": "Assistant",
|
||||
"fleetops": "Fleet & Ops",
|
||||
"hubs": "Hubs",
|
||||
"vehicles": "Vehicles",
|
||||
|
||||
Reference in New Issue
Block a user