updatess on the ui changs

This commit is contained in:
2026-08-18 12:46:50 +05:30
parent 8d154f06d9
commit e420223622
24 changed files with 6640 additions and 1797 deletions

View File

@@ -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" />,

View File

@@ -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,

View File

@@ -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>

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

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

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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>
</>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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={

View File

@@ -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>
</>
)}
</>

View File

@@ -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">

View File

@@ -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' }}>

View File

@@ -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

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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
View 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;

View File

@@ -17,6 +17,7 @@
"profitability": "Profitability",
"dispatch": "Dispatch",
"customers": "Customers",
"assistant": "Assistant",
"fleetops": "Fleet & Ops",
"hubs": "Hubs",
"vehicles": "Vehicles",