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, TruckOutlined,
WarningOutlined, WarningOutlined,
RadarChartOutlined, RadarChartOutlined,
RiseOutlined RiseOutlined,
RobotOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
// icons // icons
@@ -66,7 +67,8 @@ const icons = {
TruckOutlined, TruckOutlined,
WarningOutlined, WarningOutlined,
RadarChartOutlined, RadarChartOutlined,
RiseOutlined RiseOutlined,
RobotOutlined
}; };
// ==============================|| MENU ITEMS - SUPPORT ||============================== // // ==============================|| MENU ITEMS - SUPPORT ||============================== //
@@ -126,6 +128,13 @@ const nearle = {
url: '/doormile/customers', url: '/doormile/customers',
icon: icons.ContactsOutlined icon: icons.ContactsOutlined
}, },
{
id: 'assistant',
title: <FormattedMessage id="assistant" />,
type: 'item',
url: '/doormile/assistant',
icon: icons.RobotOutlined
},
{ {
id: 'fleetops', id: 'fleetops',
title: <FormattedMessage id="fleetops" />, title: <FormattedMessage id="fleetops" />,

View File

@@ -71,7 +71,11 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = {
delivered: 'delivered', delivered: 'delivered',
cancelled: 'cancelled' cancelled: 'cancelled'
}; };
const mapBookingStatusToDeliveryStatus = (status) => { // Exported (not just module-local) so anything else that needs to classify a
// raw booking status — the operator bot's status-breakdown intent included —
// reuses this instead of growing a second copy of BOOKING_STATUS_TO_DELIVERY_STATUS
// that can drift from it.
export const mapBookingStatusToDeliveryStatus = (status) => {
const key = String(status || '').toLowerCase(); const key = String(status || '').toLowerCase();
return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key; return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key;
}; };
@@ -636,9 +640,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => {
// meant an order re-stamped during the evening silently jumped out of the // meant an order re-stamped during the evening silently jumped out of the
// batch it was actually assigned to and into whichever window contained // batch it was actually assigned to and into whichever window contained
// the current clock time — the same orders appearing under Afternoon and // the current clock time — the same orders appearing under Afternoon and
// then Evening on the same day. Both now bucket on // then Evening on the same day. Both now bucket on `orderdate` (this
// `expecteddeliverytime`, which is stable. It remains fine to DISPLAY // row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's
// this as a "last updated" stamp, which is all the reports use it for. // table for why `expecteddeliverytime` was also tried and rejected (it's
// the promised delivery slot, not the wave the order was placed in). It
// remains fine to DISPLAY assigntime as a "last updated" stamp, which is
// all the reports use it for.
assigntime: b.updatedat, assigntime: b.updatedat,
orderstatus: mapBookingStatusToDeliveryStatus(b.status), orderstatus: mapBookingStatusToDeliveryStatus(b.status),
droplat: b.deliverylatitude, droplat: b.deliverylatitude,

View File

@@ -1,19 +1,37 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md'; import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Divider } from '@astryxdesign/core/Divider';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -47,12 +65,38 @@ const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1)
const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' }; const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const APP_USERS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const APP_USERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const AppUsers = () => { const AppUsers = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers }); const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers });
@@ -132,6 +176,129 @@ const AppUsers = () => {
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
// A search change re-scopes the list, so page 1 is the only page that
// still means anything.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [debouncedSearch]);
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as
// orders.js's/riders.js's/Tenants.js's `columns`.
const columns = useMemo(
() => [
{
id: 'serial',
size: 40,
minSize: 32,
maxSize: 48,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'name',
accessorKey: 'first_name',
size: 200,
minSize: 150,
meta: { label: 'Name' },
header: () => <SectionLabel>Name</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
},
{
id: 'email',
accessorKey: 'email',
size: 220,
minSize: 160,
meta: { label: 'Email' },
header: () => <SectionLabel>Email</SectionLabel>,
cell: ({ getValue }) => getValue()
},
{
id: 'phone',
accessorKey: 'phone',
size: 140,
minSize: 110,
meta: { label: 'Phone' },
header: () => <SectionLabel>Phone</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'role',
accessorFn: (row) => roleLabel(row.role),
size: 130,
minSize: 100,
meta: { label: 'Role' },
header: () => <SectionLabel>Role</SectionLabel>,
cell: ({ getValue }) => getValue()
},
{
id: 'status',
accessorKey: 'status',
size: 120,
minSize: 100,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'actions',
size: 120,
minSize: 100,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<IconButton
label="Edit user"
tooltip="Edit user"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row.original)}
/>
<IconButton
label="Delete user"
tooltip="Delete user"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row.original)}
/>
</HStack>
)
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[pagination.pageIndex, pagination.pageSize]
);
const table = useTable({
features: APP_USERS_TABLE_FEATURES,
data: rows,
columns,
getRowId: (row, index) => String(row.id ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />} {(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
@@ -170,24 +337,107 @@ const AppUsers = () => {
</FilterToolbar> </FilterToolbar>
} }
> >
<TableScroll minWidth={880}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="App Users"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Name</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Email</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Phone</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Role</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Status</TableHeaderCell> return (
<TableHeaderCell>Actions</TableHeaderCell> <TableHeaderCell
</TableRow> key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={7} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No staff users to show" title="No staff users to show"
description="Create a staff login to give someone console access." description="Create a staff login to give someone console access."
@@ -196,42 +446,49 @@ const AppUsers = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.id || index}> <TableRow key={tableRow.id}>
<TableCell>{index + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell> <TableCell
<Text weight="bold">{row.first_name}</Text> key={cell.id}
</TableCell> style={{
<TableCell>{row.email}</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell>{row.phone || '—'}</TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell>{roleLabel(row.role)}</TableCell> }}
<TableCell>{row.status || '—'}</TableCell> >
<TableCell> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<HStack gap={1} justify="end"> </TableCell>
<IconButton ))}
label="Edit user"
tooltip="Edit user"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete user"
tooltip="Delete user"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} user${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={APP_USERS_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="App users pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

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 { import {
MdLocalOffer, MdLocalOffer,
MdGroups, MdGroups,
@@ -13,25 +13,46 @@ import {
MdAdd, MdAdd,
MdEdit, MdEdit,
MdDeleteOutline, MdDeleteOutline,
MdCalculate MdCalculate,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md'; } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Divider } from '@astryxdesign/core/Divider';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { NumberInput } from '@astryxdesign/core/NumberInput'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -71,6 +92,29 @@ const MetricPill = ({ label }) => (
const CategoryChip = ({ icon, label }) => <Badge variant="neutral" icon={icon} label={label} />; const CategoryChip = ({ icon, label }) => <Badge variant="neutral" icon={icon} label={label} />;
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const PRICING_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const emptyForm = { const emptyForm = {
pricingid: null, pricingid: null,
tenant: '', tenant: '',
@@ -100,6 +144,11 @@ const ClientsPricing = () => {
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [quoteOpen, setQuoteOpen] = useState(false); const [quoteOpen, setQuoteOpen] = useState(false);
const [quoteForm, setQuoteForm] = useState({ tenant: '', location: '', vehicletype: 'Bike', weight: '', distance: '' }); const [quoteForm, setQuoteForm] = useState({ tenant: '', location: '', vehicletype: 'Bike', weight: '', distance: '' });
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: pricing = [], isLoading } = useQuery({ const { data: pricing = [], isLoading } = useQuery({
queryKey: ['getallpricing', appId], queryKey: ['getallpricing', appId],
@@ -151,6 +200,12 @@ const ClientsPricing = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [zonePricing, debouncedSearch, tenantMap, locationMap]); }, [zonePricing, debouncedSearch, tenantMap, locationMap]);
// A zone/search change re-scopes the list, so page 1 is the only page that
// still means anything — same reset orders.js/riders.js do.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [appId, debouncedSearch]);
const stats = useMemo(() => { const stats = useMemo(() => {
const total = zonePricing.length; const total = zonePricing.length;
const tenantsPriced = new Set(zonePricing.map((r) => r.tenantid).filter(Boolean)).size; const tenantsPriced = new Set(zonePricing.map((r) => r.tenantid).filter(Boolean)).size;
@@ -281,6 +336,166 @@ const ClientsPricing = () => {
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
const setQuote = (key) => (value) => setQuoteForm((f) => ({ ...f, [key]: value })); const setQuote = (key) => (value) => setQuoteForm((f) => ({ ...f, [key]: value }));
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as
// orders.js's/riders.js's/Tenants.js's `columns`. Tenant/Zone keep the
// widths from this file's earlier column-squeeze fix (Tenant needed real
// width so Zone's text didn't get crushed under `table-layout: auto`).
const columns = useMemo(
() => [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'tenant',
accessorFn: (row) => tenantName(row),
size: 260,
minSize: 240,
meta: { label: 'Tenant' },
header: () => <SectionLabel>Tenant</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={36}>
<MdGroups size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary">
ID #{row.original.pricingid}
</Text>
</VStack>
</HStack>
)
},
{
id: 'zone',
accessorFn: (row) => zoneName(row),
size: 180,
minSize: 160,
meta: { label: 'Zone' },
header: () => <SectionLabel>Zone</SectionLabel>,
cell: ({ getValue }) => <CategoryChip icon={<MdPlace size={12} />} label={getValue()} />
},
{
id: 'vehicle',
accessorKey: 'vehicletype',
size: 120,
minSize: 100,
meta: { label: 'Vehicle' },
header: () => <SectionLabel>Vehicle</SectionLabel>,
cell: ({ getValue }) => <CategoryChip icon={<MdSpeed size={12} />} label={getValue() || '—'} />
},
{
id: 'baseprice',
accessorKey: 'baseprice',
size: 130,
minSize: 120,
meta: { label: 'Base Price' },
header: () => <SectionLabel>Base Price</SectionLabel>,
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
},
{
id: 'priceperkm',
accessorKey: 'priceperkm',
size: 130,
minSize: 120,
meta: { label: 'Price / KM' },
header: () => <SectionLabel>Price / KM</SectionLabel>,
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
},
{
id: 'priceperkg',
accessorKey: 'priceperkg',
size: 130,
minSize: 120,
meta: { label: 'Price / KG' },
header: () => <SectionLabel>Price / KG</SectionLabel>,
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
},
{
id: 'handling',
accessorKey: 'handlingcharges',
size: 120,
minSize: 100,
meta: { label: 'Handling' },
header: () => <SectionLabel>Handling</SectionLabel>,
cell: ({ getValue }) => <MetricPill label={formatRupees(getValue())} />
},
{
id: 'status',
accessorKey: 'status',
size: 120,
minSize: 100,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => <CategoryChip icon={<MdPriceCheck size={12} />} label={getValue() || '—'} />
},
{
id: 'actions',
size: 110,
minSize: 100,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<Tooltip content="Edit rule">
<IconButton
label="Edit rule"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEditDialog(row.original)}
/>
</Tooltip>
<Tooltip content="Delete rule">
<IconButton
label="Delete rule"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row.original)}
/>
</Tooltip>
</HStack>
)
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[pagination.pageIndex, pagination.pageSize, tenantMap, locationMap]
);
const table = useTable({
features: PRICING_TABLE_FEATURES,
// `rows` is already useMemo'd above (stable reference) — required for
// pagination clicks to stick, see riders.js's fix for the same bug.
data: rows,
columns,
getRowId: (row, index) => String(row.pricingid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />} {(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && <Loader />}
@@ -344,98 +559,181 @@ const ClientsPricing = () => {
/> />
) : ( ) : (
<MobileCardList scroll> <MobileCardList scroll>
{rows.map((row, index) => ( {table.getRowModel().rows.map((tableRow) => {
<MobileCard const row = tableRow.original;
key={row.pricingid || `${row.tenantid}-${index}`} return (
accent={BRAND} <MobileCard
header={ key={tableRow.id}
<HStack justify="between" vAlign="center" gap={1}> accent={BRAND}
<HStack gap={2} vAlign="center" minWidth={0}> header={
<AccentAvatar color={BRAND} size={36}> <HStack justify="between" vAlign="center" gap={1}>
<MdGroups size={18} /> <HStack gap={2} vAlign="center" minWidth={0}>
</AccentAvatar> <AccentAvatar color={BRAND} size={36}>
<VStack gap={0} padding={0} minWidth={0}> <MdGroups size={18} />
<Text weight="bold" maxLines={1}> </AccentAvatar>
{tenantName(row)} <VStack gap={0} padding={0} minWidth={0}>
</Text> <Text weight="bold" maxLines={1}>
<Text type="supporting" color="secondary"> {tenantName(row)}
ID #{row.pricingid} </Text>
</Text> <Text type="supporting" color="secondary">
</VStack> ID #{row.pricingid}
</HStack> </Text>
<HStack gap={0.5}> </VStack>
<IconButton </HStack>
label="Edit rule" <HStack gap={0.5}>
tooltip="Edit rule" <IconButton
size="sm" label="Edit rule"
variant="ghost" tooltip="Edit rule"
icon={<MdEdit size={14} />} size="sm"
onClick={() => openEditDialog(row)} variant="ghost"
/> icon={<MdEdit size={14} />}
<IconButton onClick={() => openEditDialog(row)}
label="Delete rule" />
tooltip="Delete rule" <IconButton
size="sm" label="Delete rule"
variant="destructive" tooltip="Delete rule"
icon={<MdDeleteOutline size={14} />} size="sm"
onClick={() => handleDelete(row)} variant="destructive"
/> icon={<MdDeleteOutline size={14} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</HStack> </HStack>
}
>
<HStack gap={1} wrap="wrap" style={{ marginTop: 8 }}>
<Badge variant="green" icon={<MdPlace size={12} />} label={zoneName(row)} />
<Badge variant="blue" icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
</HStack> </HStack>
}
>
<HStack gap={1} wrap="wrap" style={{ marginTop: 8 }}>
<Badge variant="green" icon={<MdPlace size={12} />} label={zoneName(row)} />
<Badge variant="blue" icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
</HStack>
<MobileFieldGrid> <MobileFieldGrid>
<MobileField label="Base Price"> <MobileField label="Base Price">
<MetricPill label={formatRupees(row.baseprice)} /> <MetricPill label={formatRupees(row.baseprice)} />
</MobileField> </MobileField>
<MobileField label="Price / KM"> <MobileField label="Price / KM">
<MetricPill label={formatRupees(row.priceperkm)} /> <MetricPill label={formatRupees(row.priceperkm)} />
</MobileField> </MobileField>
<MobileField label="Price / KG"> <MobileField label="Price / KG">
<MetricPill label={formatRupees(row.priceperkg)} /> <MetricPill label={formatRupees(row.priceperkg)} />
</MobileField> </MobileField>
<MobileField label="Handling"> <MobileField label="Handling">
<MetricPill label={formatRupees(row.handlingcharges)} /> <MetricPill label={formatRupees(row.handlingcharges)} />
</MobileField> </MobileField>
<MobileField label="Status"> <MobileField label="Status">
<HStack gap={1} vAlign="center"> <HStack gap={1} vAlign="center">
<MdReceiptLong size={14} color={DT.textMuted} /> <MdReceiptLong size={14} color={DT.textMuted} />
<Text weight="bold">{row.status || '—'}</Text> <Text weight="bold">{row.status || '—'}</Text>
</HStack> </HStack>
</MobileField> </MobileField>
</MobileFieldGrid> </MobileFieldGrid>
</MobileCard> </MobileCard>
))} );
})}
</MobileCardList> </MobileCardList>
) )
) : ( ) : (
<TableScroll minWidth={1260}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Pricing"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell style={{ minWidth: 240 }}>Tenant</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell style={{ minWidth: 160 }}>Zone</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell style={{ minWidth: 100 }}>Vehicle</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell style={{ minWidth: 120 }}>Base Price</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell style={{ minWidth: 120 }}>Price / KM</TableHeaderCell> return (
<TableHeaderCell style={{ minWidth: 120 }}>Price / KG</TableHeaderCell> <TableHeaderCell
<TableHeaderCell style={{ minWidth: 100 }}>Handling</TableHeaderCell> key={header.id}
<TableHeaderCell style={{ minWidth: 100 }}>Status</TableHeaderCell> scope="col"
<TableHeaderCell style={{ minWidth: 100 }}>Actions</TableHeaderCell> aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
</TableRow> style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={10} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={10}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No pricing to show" title="No pricing to show"
description={searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'} description={searchword ? 'Try a different keyword.' : 'Create a pricing rule to get started.'}
@@ -444,80 +742,19 @@ const ClientsPricing = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.pricingid || `${row.tenantid}-${index}`}> <TableRow key={tableRow.id}>
<TableCell> {tableRow.getAllCells().map((cell) => (
<Text type="supporting" weight="bold" color="secondary"> <TableCell
{index + 1} key={cell.id}
</Text> style={{
</TableCell> textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell> }}
<HStack gap={2} vAlign="center"> >
<AccentAvatar color={BRAND} size={36}> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<MdGroups size={18} /> </TableCell>
</AccentAvatar> ))}
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{tenantName(row)}
</Text>
<Text type="supporting" color="secondary">
ID #{row.pricingid}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell>
<CategoryChip icon={<MdPlace size={12} />} label={zoneName(row)} />
</TableCell>
<TableCell>
<CategoryChip icon={<MdSpeed size={12} />} label={row.vehicletype || '—'} />
</TableCell>
<TableCell>
<MetricPill label={formatRupees(row.baseprice)} />
</TableCell>
<TableCell>
<MetricPill label={formatRupees(row.priceperkm)} />
</TableCell>
<TableCell>
<MetricPill label={formatRupees(row.priceperkg)} />
</TableCell>
<TableCell>
<MetricPill label={formatRupees(row.handlingcharges)} />
</TableCell>
<TableCell>
<CategoryChip icon={<MdPriceCheck size={12} />} label={row.status || '—'} />
</TableCell>
<TableCell>
<HStack gap={1} justify="end">
<Tooltip content="Edit rule">
<IconButton
label="Edit rule"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEditDialog(row)}
/>
</Tooltip>
<Tooltip content="Delete rule">
<IconButton
label="Delete rule"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</Tooltip>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
@@ -525,6 +762,30 @@ const ClientsPricing = () => {
</Table> </Table>
</TableScroll> </TableScroll>
)} )}
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} pricing rule${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Pricing pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

View File

@@ -24,18 +24,45 @@ import {
MdEdit, MdEdit,
MdDeleteOutline, MdDeleteOutline,
MdPeopleAlt, MdPeopleAlt,
MdVisibility, MdArrowUpward,
MdVisibilityOff, MdArrowDownward,
MdBlock, MdUnfoldMore,
MdClose // Outline-only set for the Actions column — kept separate from the filled
// icons above (MdPlace/MdEdit/MdPeopleAlt), which stay filled for the
// Locations/Customers/Pricing dialogs elsewhere in this file. Mixing
// filled and outlined glyphs in one row is what read as inconsistent.
MdOutlineBlock,
MdOutlineVisibility,
MdOutlineVisibilityOff,
MdOutlineEdit,
MdOutlineClose,
MdOutlineMoreVert,
MdOutlinePlace,
MdOutlinePeopleAlt
} from 'react-icons/md'; } from 'react-icons/md';
import { LuMail } from 'react-icons/lu'; import { LuMail } from 'react-icons/lu';
import { BiUser } from 'react-icons/bi'; import { BiUser } from 'react-icons/bi';
// TanStack Table v9 — same engine orders.js/riders.js use, so sorting/
// resizing/pagination behave identically across the console's list pages.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -46,6 +73,7 @@ import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { TabList, Tab } from '@astryxdesign/core/TabList'; import { TabList, Tab } from '@astryxdesign/core/TabList';
import { Pagination } from '@astryxdesign/core/Pagination'; import { Pagination } from '@astryxdesign/core/Pagination';
import { DropdownMenu } from '@astryxdesign/core/DropdownMenu';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
@@ -103,6 +131,27 @@ const KPI_META = [
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v })); const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const TENANTS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
// Uppercase micro-label for the table's column headings — same convention
// orders.js's and riders.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// ==============================|| Tenant locations (branches/sites) dialog ||============================== // // ==============================|| Tenant locations (branches/sites) dialog ||============================== //
// Self-contained — deliberately does not touch the existing openRowIndex1/2 + // Self-contained — deliberately does not touch the existing openRowIndex1/2 +
// Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT // Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT
@@ -547,6 +596,14 @@ const Clients1 = () => {
const [latlong, setLatlong] = useState({}); const [latlong, setLatlong] = useState({});
const [editClient, setEditClient] = useState({}); const [editClient, setEditClient] = useState({});
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
// Table sorting/resizing state — mirrors orders.js's/riders.js's slices.
// Pagination itself stays on the existing page/rowsPerPage state (below)
// rather than a new `pagination` object, since page/rowsPerPage already
// feed the tenantList query key and several onClick handlers — bridged
// into TanStack's { pageIndex, pageSize } shape via paginationState/
// handlePaginationChange further down.
const [sorting, setSorting] = useState([]);
const [columnSizing, setColumnSizing] = useState({});
const dialogclose = () => { const dialogclose = () => {
setDialogopen(false); setDialogopen(false);
@@ -822,15 +879,43 @@ const Clients1 = () => {
const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active; const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active;
const renderRowActions = (row, index, expanded, size = 'sm') => ( // Icon buttons only, kept to 3 max + one overflow menu: status action,
<HStack gap={0.75} justify="end"> // view details, edit, then "More" (Locations/Customers — secondary,
// low-frequency actions) behind a kebab menu.
//
// Sizing: `size="md"` is Astryx's own 32px button height (confirmed in
// Button's sizeStyles — sm=28px/md=32px/lg=36px) and icon-only buttons are
// always square, so this is a native 32×32px button, not a manually forced
// one. Its border-radius is Astryx's own default --radius-element (8px —
// see the identical note in dispatch/Dispatch.css), so no radius override
// is needed either — both requirements were already the design system's
// own defaults once the size prop was corrected.
//
// Icon family: every icon here is the Material "outline" variant
// (MdOutline*). Mixing MdBlock/MdVisibility/MdEdit/MdClose/MdMoreVert
// (filled) with MdOutlineCheckCircle (outline) — the state before this
// pass — is what actually read as inconsistent, not the icons' size or
// position. MdPlace/MdEdit/MdPeopleAlt stay filled where they're used
// elsewhere in this file (Locations/Customers/Pricing dialogs) — only this
// row switches to the Outline set.
//
// Colour: the status-action slot (Inactivate/Approve/Activate — whichever
// one applies to the active tab) is the one action with real consequence
// for the tenant's account state, so it keeps the `destructive` (red-
// tinted) treatment across all three tab variants for a consistent,
// predictable "this is the serious one" signal. View/Edit/More stay
// `ghost` (transparent, neutral) — `primary` only kicks in on View/Edit
// when their panel is the one currently open, an existing state indicator
// preserved as-is, not a new style.
const renderRowActions = (row, index, expanded, size = 'md') => (
<HStack gap={2} vAlign="center" justify="end" style={{ paddingRight: 'var(--spacing-4)' }}>
{value0 == 0 && ( {value0 == 0 && (
<IconButton <IconButton
label="Inactivate" label="Inactivate"
tooltip="Inactivate" tooltip="Inactivate"
size={size} size={size}
variant="destructive" variant="destructive"
icon={<MdBlock size={14} />} icon={<MdOutlineBlock size={17} />}
onClick={() => { onClick={() => {
setSelectedCustomer(row); setSelectedCustomer(row);
setSelectedtenid(row.tenantid); setSelectedtenid(row.tenantid);
@@ -846,8 +931,8 @@ const Clients1 = () => {
label="Approve" label="Approve"
tooltip="Approve" tooltip="Approve"
size={size} size={size}
variant="ghost" variant="destructive"
icon={<FaRegCheckCircle size={13} />} icon={<MdOutlineCheckCircle size={17} />}
onClick={() => { onClick={() => {
setSelectedCustomer(row); setSelectedCustomer(row);
setDialogopen(true); setDialogopen(true);
@@ -862,8 +947,8 @@ const Clients1 = () => {
label="Activate" label="Activate"
tooltip="Activate" tooltip="Activate"
size={size} size={size}
variant="ghost" variant="destructive"
icon={<FaRegCheckCircle size={13} />} icon={<MdOutlineCheckCircle size={17} />}
onClick={() => { onClick={() => {
setSelectedCustomer(row); setSelectedCustomer(row);
setSelectedtenid(row.tenantid); setSelectedtenid(row.tenantid);
@@ -879,7 +964,7 @@ const Clients1 = () => {
tooltip={expanded ? 'Hide details' : 'View details'} tooltip={expanded ? 'Hide details' : 'View details'}
size={size} size={size}
variant={expanded ? 'primary' : 'ghost'} variant={expanded ? 'primary' : 'ghost'}
icon={expanded ? <MdVisibilityOff size={14} /> : <MdVisibility size={14} />} icon={expanded ? <MdOutlineVisibilityOff size={17} /> : <MdOutlineVisibility size={17} />}
onClick={() => { onClick={() => {
setSelectedCustomer(row); setSelectedCustomer(row);
handleCollapseToggle1(index); handleCollapseToggle1(index);
@@ -887,29 +972,13 @@ const Clients1 = () => {
setSelectedtenid(row.tenantid); setSelectedtenid(row.tenantid);
}} }}
/> />
<IconButton
label="Locations"
tooltip="Locations"
size={size}
variant="ghost"
icon={<MdPlace size={14} />}
onClick={() => setLocationsDialogTenant(row)}
/>
<IconButton
label="Customers"
tooltip="Customers"
size={size}
variant="ghost"
icon={<MdPeopleAlt size={14} />}
onClick={() => setCustomersDialogTenant(row)}
/>
{value0 !== 1 && ( {value0 !== 1 && (
<IconButton <IconButton
label={openRowIndex2 === index ? 'Close' : 'Edit'} label={openRowIndex2 === index ? 'Close' : 'Edit'}
tooltip={openRowIndex2 === index ? 'Close' : 'Edit'} tooltip={openRowIndex2 === index ? 'Close' : 'Edit'}
size={size} size={size}
variant={openRowIndex2 === index ? 'primary' : 'ghost'} variant={openRowIndex2 === index ? 'primary' : 'ghost'}
icon={openRowIndex2 === index ? <MdClose size={14} /> : <MdEdit size={14} />} icon={openRowIndex2 === index ? <MdOutlineClose size={17} /> : <MdOutlineEdit size={17} />}
onClick={() => { onClick={() => {
setSelectedCustomer(row); setSelectedCustomer(row);
handleCollapseToggle2(index); handleCollapseToggle2(index);
@@ -919,6 +988,22 @@ const Clients1 = () => {
}} }}
/> />
)} )}
<DropdownMenu
button={{
label: 'More actions',
tooltip: 'More actions',
variant: 'ghost',
size,
icon: <MdOutlineMoreVert size={17} />,
isIconOnly: true
}}
menuWidth={180}
placement="below"
items={[
{ label: 'Locations', icon: <MdOutlinePlace size={16} />, onClick: () => setLocationsDialogTenant(row) },
{ label: 'Customers', icon: <MdOutlinePeopleAlt size={16} />, onClick: () => setCustomersDialogTenant(row) }
]}
/>
</HStack> </HStack>
); );
@@ -1049,6 +1134,146 @@ const Clients1 = () => {
</MainCard> </MainCard>
); );
// Bridges TanStack's { pageIndex, pageSize } pagination shape onto the
// existing page/rowsPerPage state, so every other reader of those two
// values (the tenantList query key, handleChange, the page-size onChange
// below) keeps working unchanged.
const paginationState = { pageIndex: page, pageSize: rowsPerPage };
const handlePaginationChange = (updater) => {
const next = typeof updater === 'function' ? updater(paginationState) : updater;
if (next.pageIndex !== page) setPage(next.pageIndex);
if (next.pageSize !== rowsPerPage) setRowsPerPage(next.pageSize);
};
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as orders.js's
// and riders.js's `columns`. Deliberately NOT wrapped in useMemo: the
// Actions column's cell calls `renderRowActions`, which closes over value0/
// openRowIndex2/several setters that change often — memoizing here would
// need an unmanageable dependency list, and recomputing ~6 column defs a
// render is cheap. Row-expand state is keyed by `row.tenantid` (not
// position), so it stays correct once sorting/pagination reorder rows.
const columns = [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(page * rowsPerPage + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'status',
accessorKey: 'status',
size: 140,
minSize: 110,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => {
const meta = STATUS_META[String(getValue() || '').toLowerCase()] || STATUS_META.active;
const Icon = meta.icon;
return <Badge variant={meta.badge} icon={<Icon size={12} />} label={meta.label} />;
}
},
{
id: 'client',
accessorKey: 'tenantname',
size: 260,
minSize: 200,
meta: { label: 'Client' },
header: () => <SectionLabel>Client</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={BRAND} selected size={32}>
<MdPersonPin size={16} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary">
ID #{row.original.tenantid}
</Text>
</VStack>
</HStack>
)
},
{
id: 'contact',
accessorKey: 'primarycontact',
size: 220,
minSize: 160,
meta: { label: 'Contact' },
header: () => <SectionLabel>Contact</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue() || '—'}
</Text>
<Text type="supporting" color="secondary">
{row.original.primaryemail || '—'}
</Text>
</VStack>
)
},
{
id: 'deliveryOtp',
accessorFn: (row) => (row.requiredeliveryotp ? 'Yes' : 'No'),
size: 150,
minSize: 130,
meta: { label: 'Delivery OTP', align: 'center' },
header: () => <SectionLabel>Delivery OTP</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" color="secondary">
{getValue()}
</Text>
)
},
{
id: 'actions',
// Up to 4 controls: status action + view details + edit + a "More"
// overflow menu (Locations/Customers) — wide enough that 32px buttons
// with 8px gaps never wrap to a second line.
size: 220,
minSize: 190,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => renderRowActions(row.original, row.original.tenantid, openRowIndex1 === row.original.tenantid)
}
];
const table = useTable({
features: TENANTS_TABLE_FEATURES,
// filteredTenantList is already useMemo'd above (stable reference across
// renders) — required for TanStack's row-model memoization to actually
// hold and for pagination clicks to stick (an unmemoized array here
// reads as "data changed" on every render and auto-resets to page 1,
// the exact bug fixed on riders.js's table).
data: filteredTenantList,
columns,
getRowId: (row, index) => String(row.tenantid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination: paginationState, columnSizing },
onSortingChange: setSorting,
onPaginationChange: handlePaginationChange,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{(getalltenantsIsLoading || isloader) && <Loader />} {(getalltenantsIsLoading || isloader) && <Loader />}
@@ -1140,24 +1365,108 @@ const Clients1 = () => {
} }
> >
{!isMobile && ( {!isMobile && (
<TableScroll minWidth={960}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's
tables. Header and body are both rendered FROM the column
model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Tenants"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Status</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Client</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Contact</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Delivery OTP</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Actions</TableHeaderCell> return (
</TableRow> <TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{getalltenantsIsLoading && <TableLoader rows={5} columns={6} />} {getalltenantsIsLoading && <TableLoader rows={5} columns={colCount} />}
{filteredTenantList.length == 0 && !isloader ? ( {filteredTenantList.length == 0 && !isloader ? (
<TableRow> <TableRow>
<TableCell colSpan={6}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No tenants to show" title="No tenants to show"
description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`} description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
@@ -1166,65 +1475,37 @@ const Clients1 = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredTenantList.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row, index) => { table.getRowModel().rows.map((tableRow) => {
const rowStatusKey = String(row.status || '').toLowerCase(); const row = tableRow.original;
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active; const expanded1 = openRowIndex1 === row.tenantid;
const RowStatusIcon = rowStatusMeta.icon; const expanded2 = openRowIndex2 === row.tenantid;
return ( return (
<Fragment key={row.tenantid ?? index}> <Fragment key={tableRow.id}>
<TableRow> <TableRow>
<TableCell> {tableRow.getAllCells().map((cell) => (
<Text type="supporting" weight="bold" color="secondary"> <TableCell
{String(index + 1 + page * rowsPerPage).padStart(2, '0')} key={cell.id}
</Text> style={{
</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={12} />} label={rowStatusMeta.label} /> }}
</TableCell> >
<TableCell> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<HStack gap={1.5} vAlign="center"> </TableCell>
<AccentAvatar color={BRAND} selected size={32}> ))}
<MdPersonPin size={16} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{row.tenantname}
</Text>
<Text type="supporting" color="secondary">
ID #{row.tenantid}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{row.primarycontact || '—'}
</Text>
<Text type="supporting" color="secondary">
{row.primaryemail || '—'}
</Text>
</VStack>
</TableCell>
<TableCell>
<Text type="supporting" color="secondary">
{row.requiredeliveryotp ? 'Yes' : 'No'}
</Text>
</TableCell>
<TableCell>{renderRowActions(row, index, openRowIndex1 === index)}</TableCell>
</TableRow> </TableRow>
{openRowIndex1 === index && ( {expanded1 && (
<TableRow> <TableRow>
<TableCell colSpan={6} style={{ padding: 12 }}> <TableCell colSpan={colCount} style={{ padding: 12 }}>
{renderDetailsPanel(row)} {renderDetailsPanel(row)}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{openRowIndex2 === index && ( {expanded2 && (
<TableRow> <TableRow>
<TableCell colSpan={6} style={{ padding: 12 }}> <TableCell colSpan={colCount} style={{ padding: 12 }}>
{renderEditPanel(row)} {renderEditPanel(row)}
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -1247,14 +1528,15 @@ const Clients1 = () => {
icon={<MdGroups size={28} />} icon={<MdGroups size={28} />}
/> />
) : ( ) : (
filteredTenantList.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row, index) => { table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const rowStatusKey = String(row.status || '').toLowerCase(); const rowStatusKey = String(row.status || '').toLowerCase();
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active; const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
const RowStatusIcon = rowStatusMeta.icon; const RowStatusIcon = rowStatusMeta.icon;
const expanded = openRowIndex1 === index; const expanded = openRowIndex1 === row.tenantid;
return ( return (
<MobileCard <MobileCard
key={row.tenantid ?? index} key={tableRow.id}
accent={rowStatusMeta.color} accent={rowStatusMeta.color}
header={ header={
<HStack justify="between" vAlign="start" gap={1}> <HStack justify="between" vAlign="start" gap={1}>
@@ -1269,7 +1551,7 @@ const Clients1 = () => {
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={10} />} label={rowStatusMeta.label} /> <Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={10} />} label={rowStatusMeta.label} />
</VStack> </VStack>
</HStack> </HStack>
{renderRowActions(row, index, expanded)} {renderRowActions(row, row.tenantid, expanded)}
</HStack> </HStack>
} }
> >
@@ -1294,10 +1576,20 @@ const Clients1 = () => {
</MobileCardList> </MobileCardList>
)} )}
{!searchword && filteredTenantList.length > 0 && ( {/* Page-number pagination, matching orders.js's/riders.js's
footer — previously hidden while `searchword` was set (a
search result past the first page had no way to reach it),
and used the "count"-only variant (no page-number buttons)
instead of the numbered pager the other two tables use. */}
{filteredTenantList.length > 0 && (
<> <>
<Divider /> <Divider />
<HStack justify="end" padding={3}> <HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${page * rowsPerPage + 1}–${Math.min((page + 1) * rowsPerPage, filteredTenantList.length)} of ${
filteredTenantList.length
} tenant${filteredTenantList.length === 1 ? '' : 's'}`}
</Text>
<Pagination <Pagination
page={page + 1} page={page + 1}
onChange={(p) => setPage(p - 1)} onChange={(p) => setPage(p - 1)}
@@ -1308,7 +1600,8 @@ const Clients1 = () => {
setRowsPerPage(size); setRowsPerPage(size);
setPage(0); setPage(0);
}} }}
variant="count" size="sm"
label="Tenants pagination"
/> />
</HStack> </HStack>
</> </>

View File

@@ -1,10 +1,24 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md'; import { MdRadar, MdAdd, MdEdit, MdDeleteOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -75,10 +89,41 @@ const emptyBranch = {
const emptyCarrier = { id: null, company: '', weight_slab: '', service_type: '', zone: '', rate: '', delivery_time: '' }; const emptyCarrier = { id: null, company: '', weight_slab: '', service_type: '', zone: '', rate: '', delivery_time: '' };
const PAGE_SIZE = 50; const PAGE_SIZE = 50;
// TanStack Table v9 registers features explicitly — sorting/resizing/
// pagination, same as every other table converted this session. No
// `paginatedRowModel` factory: this page's pages are fetched from the
// server (GET /admin/competitor-branches|carrier-pricing, page/PAGE_SIZE
// query params), not client-sliced, so the table runs in `manualPagination`
// mode — `data` IS the current page already, and TanStack skips its own
// slicing whenever `manualPagination: true` regardless of a registered
// row-model factory (confirmed by reading table-core's
// table_getPaginatedRowModel).
const COMPETITIVE_INTEL_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature
});
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const CompetitiveIntel = () => { const CompetitiveIntel = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [tab, setTab] = useState('branches'); const [tab, setTab] = useState('branches');
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
// Table sorting/resizing state — mirrors orders.js's/riders.js's slices.
// Pagination itself stays on `page` above (bridged onto TanStack's shape
// further down) since it drives the server-side fetch.
const [sorting, setSorting] = useState([]);
const [columnSizing, setColumnSizing] = useState({});
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [branchForm, setBranchForm] = useState(emptyBranch); const [branchForm, setBranchForm] = useState(emptyBranch);
const [carrierForm, setCarrierForm] = useState(emptyCarrier); const [carrierForm, setCarrierForm] = useState(emptyCarrier);
@@ -86,6 +131,7 @@ const CompetitiveIntel = () => {
const changeTab = (t) => { const changeTab = (t) => {
setTab(t); setTab(t);
setPage(1); setPage(1);
setSorting([]);
}; };
const { data: branchesRes, isLoading: branchesLoading } = useQuery({ const { data: branchesRes, isLoading: branchesLoading } = useQuery({
@@ -224,6 +270,217 @@ const CompetitiveIntel = () => {
const setBranch = (key) => (value) => setBranchForm((f) => ({ ...f, [key]: value })); const setBranch = (key) => (value) => setBranchForm((f) => ({ ...f, [key]: value }));
const setCarrier = (key) => (value) => setCarrierForm((f) => ({ ...f, [key]: value })); const setCarrier = (key) => (value) => setCarrierForm((f) => ({ ...f, [key]: value }));
// `rows` above is a fresh array per render (`branches`/`carrierPricing`
// themselves come straight off useQuery's `data`, which React Query keeps
// referentially stable across renders when the response is unchanged) —
// still wrapped here for a guaranteed-stable reference, matching the fix
// applied on riders.js so pagination clicks can't hit the auto-reset bug.
const tableRows = useMemo(() => rows, [rows]);
// Bridges TanStack's { pageIndex, pageSize } pagination shape onto the
// existing 1-based `page` state, which is the actual source of truth (it
// drives the server-side fetch via the query key).
const paginationState = { pageIndex: page - 1, pageSize: PAGE_SIZE };
const handlePaginationChange = (updater) => {
const next = typeof updater === 'function' ? updater(paginationState) : updater;
if (next.pageIndex !== page - 1) setPage(next.pageIndex + 1);
};
// The column model switches with `tab` — branches and pricing are two
// logically different tables sharing one <Table>. Deliberately NOT
// wrapped in useMemo: the Actions column's cell closes over `openEdit`/
// `handleDelete`, which close over `tab` themselves — memoizing here would
// need `tab` (and effectively everything) in the dep list anyway, and
// recomputing ~6-7 column defs a render is cheap (same reasoning as
// Tenants.js's `columns`).
const columns =
tab === 'branches'
? [
{
id: 'serial',
size: 50,
minSize: 40,
maxSize: 60,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (page - 1) * PAGE_SIZE + t.getRowModel().rows.indexOf(row) + 1
},
{
id: 'company',
accessorKey: 'company',
size: 200,
minSize: 140,
meta: { label: 'Company' },
header: () => <SectionLabel>Company</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'area',
accessorKey: 'area',
size: 160,
minSize: 120,
meta: { label: 'Area' },
header: () => <SectionLabel>Area</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'phone',
accessorKey: 'phone',
size: 140,
minSize: 110,
meta: { label: 'Phone' },
header: () => <SectionLabel>Phone</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'address',
accessorKey: 'address',
size: 240,
minSize: 160,
meta: { label: 'Address' },
header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ getValue }) => (
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
{getValue() || '—'}
</Text>
)
},
{
id: 'rate_per_kg',
accessorKey: 'rate_per_kg',
size: 120,
minSize: 100,
meta: { label: 'Rate/Kg' },
header: () => <SectionLabel>Rate/Kg</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'actions',
size: 110,
minSize: 90,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<IconButton
label="Edit"
tooltip="Edit"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row.original)}
/>
<IconButton
label="Delete"
tooltip="Delete"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row.original)}
/>
</HStack>
)
}
]
: [
{
id: 'serial',
size: 50,
minSize: 40,
maxSize: 60,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (page - 1) * PAGE_SIZE + t.getRowModel().rows.indexOf(row) + 1
},
{
id: 'company',
accessorKey: 'company',
size: 200,
minSize: 140,
meta: { label: 'Company' },
header: () => <SectionLabel>Company</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'weight_slab',
accessorKey: 'weight_slab',
size: 160,
minSize: 120,
meta: { label: 'Weight Slab' },
header: () => <SectionLabel>Weight Slab</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'zone',
accessorKey: 'zone',
size: 160,
minSize: 120,
meta: { label: 'Zone' },
header: () => <SectionLabel>Zone</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'rate',
accessorKey: 'rate',
size: 140,
minSize: 110,
meta: { label: 'Rate' },
header: () => <SectionLabel>Rate</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'actions',
size: 110,
minSize: 90,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<IconButton
label="Edit"
tooltip="Edit"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row.original)}
/>
<IconButton
label="Delete"
tooltip="Delete"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row.original)}
/>
</HStack>
)
}
];
const table = useTable({
features: COMPETITIVE_INTEL_TABLE_FEATURES,
data: tableRows,
columns,
manualPagination: true,
pageCount,
getRowId: (row, index) => String(row.id ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination: paginationState, columnSizing },
onSortingChange: setSorting,
onPaginationChange: handlePaginationChange,
onColumnSizingChange: setColumnSizing
});
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />} {(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
@@ -259,35 +516,107 @@ const CompetitiveIntel = () => {
</TabList> </TabList>
} }
> >
<TableScroll minWidth={tab === 'branches' ? 920 : 780}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Competitive Intel"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
{tab === 'branches' ? ( {table.getHeaderGroups().map((headerGroup) => (
<TableRow> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Company</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Area</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Phone</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Address</TableHeaderCell> return (
<TableHeaderCell>Rate/Kg</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>Actions</TableHeaderCell> key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow> </TableRow>
) : ( ))}
<TableRow>
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
<TableHeaderCell>Company</TableHeaderCell>
<TableHeaderCell>Weight Slab</TableHeaderCell>
<TableHeaderCell>Zone</TableHeaderCell>
<TableHeaderCell>Rate</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
</TableRow>
)}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={7} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={7}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No records to show" title="No records to show"
description="Add a record to start benchmarking against it." description="Add a record to start benchmarking against it."
@@ -296,49 +625,19 @@ const CompetitiveIntel = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.id || index}> <TableRow key={tableRow.id}>
<TableCell>{(page - 1) * PAGE_SIZE + index + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
{tab === 'branches' ? ( <TableCell
<> key={cell.id}
<TableCell>{row.company || '—'}</TableCell> style={{
<TableCell>{row.area || '—'}</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell>{row.phone || '—'}</TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell style={{ maxWidth: 220 }}> }}
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.address}> >
{row.address || '—'} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</Text> </TableCell>
</TableCell> ))}
<TableCell>{row.rate_per_kg || '—'}</TableCell>
</>
) : (
<>
<TableCell>{row.company || '—'}</TableCell>
<TableCell>{row.weight_slab || '—'}</TableCell>
<TableCell>{row.zone || '—'}</TableCell>
<TableCell>{row.rate || '—'}</TableCell>
</>
)}
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Edit"
tooltip="Edit"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete"
tooltip="Delete"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
@@ -350,7 +649,7 @@ const CompetitiveIntel = () => {
<> <>
<Divider /> <Divider />
<HStack justify="center" paddingBlock={2}> <HStack justify="center" paddingBlock={2}>
<Pagination page={page} onChange={setPage} totalPages={pageCount} /> <Pagination page={page} onChange={setPage} totalPages={pageCount} size="sm" label="Competitive intel pagination" />
</HStack> </HStack>
</> </>
)} )}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
@@ -11,12 +11,31 @@ import {
MdOutlineMailOutline, MdOutlineMailOutline,
MdOutlinePhoneAndroid, MdOutlinePhoneAndroid,
MdPersonAddAlt1, MdPersonAddAlt1,
MdOutlineReceiptLong MdOutlineReceiptLong,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md'; } from 'react-icons/md';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -26,6 +45,7 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Divider } from '@astryxdesign/core/Divider'; import { Divider } from '@astryxdesign/core/Divider';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -64,6 +84,29 @@ const custPhone = (row) => row.phone || '—';
const custEmail = (row) => row.email || '—'; const custEmail = (row) => row.email || '—';
const custBookings = (row) => row.totalbookings ?? 0; const custBookings = (row) => row.totalbookings ?? 0;
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const CUSTOMERS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const CUSTOMERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const Customers = () => { const Customers = () => {
const isMobile = useIsNarrowViewport(); const isMobile = useIsNarrowViewport();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -75,6 +118,9 @@ const Customers = () => {
const [addressInput, setAddressInput] = useState(''); const [addressInput, setAddressInput] = useState('');
const [pickAddress, setPickAddress] = useState({}); const [pickAddress, setPickAddress] = useState({});
const [latLng, setLatLng] = useState({ latitude: '', longitude: '' }); const [latLng, setLatLng] = useState({ latitude: '', longitude: '' });
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers }); const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
@@ -158,6 +204,132 @@ const Customers = () => {
setAddressInput(place.formatted_address); setAddressInput(place.formatted_address);
}; };
// A search re-scopes the list, so page 1 is the only page that still
// means anything — same reset orders.js/riders.js apply.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [debouncedSearch]);
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as orders.js's/
// riders.js's/Tenants.js's `columns`. Not wrapped in useMemo: the Actions
// column's cell calls `openEdit`, which is cheap to recompute per render
// and safe here since it closes only over stable setState functions.
const columns = [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'customer',
accessorFn: (row) => custName(row),
size: 260,
minSize: 200,
meta: { label: 'Customer' },
header: () => <SectionLabel>Customer</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={16} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="bold">{getValue()}</Text>
<Text type="supporting" color="secondary">
ID #{custId(row.original) ?? '—'}
</Text>
</VStack>
</HStack>
)
},
{
id: 'contact',
accessorFn: (row) => custPhone(row),
size: 240,
minSize: 180,
meta: { label: 'Contact' },
header: () => <SectionLabel>Contact</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={0} padding={0}>
<HStack gap={1} vAlign="center">
<MdPhone size={12} color={DT.textMuted} />
<Text weight="bold">{getValue()}</Text>
</HStack>
{row.original.email && (
<HStack gap={1} vAlign="center">
<MdMail size={12} color={DT.textMuted} />
<Text type="supporting" color="secondary">
{row.original.email}
</Text>
</HStack>
)}
</VStack>
)
},
{
id: 'bookings',
accessorFn: (row) => custBookings(row),
size: 130,
minSize: 100,
meta: { label: 'Bookings' },
header: () => <SectionLabel>Bookings</SectionLabel>,
cell: ({ getValue }) => <Text hasTabularNumbers>{getValue()}</Text>
},
{
id: 'actions',
size: 110,
minSize: 90,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack justify="end">
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row.original)}
/>
</HStack>
)
}
];
const table = useTable({
features: CUSTOMERS_TABLE_FEATURES,
// `rows` is already useMemo'd above (stable reference across renders) —
// required for TanStack's row-model memoization to hold and for
// pagination clicks to stick (an unmemoized array reads as "data
// changed" on every render and auto-resets to page 1).
data: rows,
columns,
getRowId: (row, index) => String(custId(row) ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{isLoading && <Loader />} {isLoading && <Loader />}
@@ -224,62 +396,150 @@ const Customers = () => {
/> />
) : ( ) : (
<MobileCardList scroll> <MobileCardList scroll>
{rows.map((row, index) => ( {table.getRowModel().rows.map((tableRow) => {
<MobileCard const row = tableRow.original;
key={custId(row) || index} return (
accent={BRAND} <MobileCard
header={ key={tableRow.id}
<HStack justify="between" vAlign="center" gap={1}> accent={BRAND}
<HStack gap={2} vAlign="center" minWidth={0}> header={
<AccentAvatar color={BRAND} size={36}> <HStack justify="between" vAlign="center" gap={1}>
<MdPeopleAlt size={18} /> <HStack gap={2} vAlign="center" minWidth={0}>
</AccentAvatar> <AccentAvatar color={BRAND} size={36}>
<VStack gap={0} padding={0} minWidth={0}> <MdPeopleAlt size={18} />
<Text weight="bold" maxLines={1}> </AccentAvatar>
{custName(row)} <VStack gap={0} padding={0} minWidth={0}>
</Text> <Text weight="bold" maxLines={1}>
<Text type="supporting" color="secondary"> {custName(row)}
ID #{custId(row) ?? '—'} </Text>
</Text> <Text type="supporting" color="secondary">
</VStack> ID #{custId(row) ?? '—'}
</Text>
</VStack>
</HStack>
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEdit(row)}
/>
</HStack> </HStack>
<IconButton }
label="Edit customer" >
tooltip="Edit customer" <MobileFieldGrid>
size="sm" <MobileField label="Phone" value={custPhone(row)} />
variant="ghost" <MobileField label="Email" value={custEmail(row)} />
icon={<MdEdit size={14} />} <MobileField label="Bookings" value={custBookings(row)} />
onClick={() => openEdit(row)} </MobileFieldGrid>
/> </MobileCard>
</HStack> );
} })}
>
<MobileFieldGrid>
<MobileField label="Phone" value={custPhone(row)} />
<MobileField label="Email" value={custEmail(row)} />
<MobileField label="Bookings" value={custBookings(row)} />
</MobileFieldGrid>
</MobileCard>
))}
</MobileCardList> </MobileCardList>
) )
) : ( ) : (
<TableScroll minWidth={720}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Customers"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Customer</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Contact</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Bookings</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Actions</TableHeaderCell> const sorted = header.column.getIsSorted();
</TableRow> return (
<TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={5} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No customers to show" title="No customers to show"
description="Customers appear here once they've placed an order." description="Customers appear here once they've placed an order."
@@ -288,55 +548,19 @@ const Customers = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={custId(row) || index}> <TableRow key={tableRow.id}>
<TableCell> {tableRow.getAllCells().map((cell) => (
<Text type="supporting" weight="bold" color="secondary"> <TableCell
{String(index + 1).padStart(2, '0')} key={cell.id}
</Text> style={{
</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<HStack gap={2} vAlign="center"> }}
<AccentAvatar color={BRAND} size={32}> >
<MdPeopleAlt size={16} /> {flexRender(cell.column.columnDef.cell, cell.getContext())}
</AccentAvatar> </TableCell>
<VStack gap={0} padding={0}> ))}
<Text weight="bold">{custName(row)}</Text>
<Text type="supporting" color="secondary">
ID #{custId(row) ?? '—'}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell>
<VStack gap={0} padding={0}>
<HStack gap={1} vAlign="center">
<MdPhone size={12} color={DT.textMuted} />
<Text weight="bold">{custPhone(row)}</Text>
</HStack>
{row.email && (
<HStack gap={1} vAlign="center">
<MdMail size={12} color={DT.textMuted} />
<Text type="supporting" color="secondary">
{row.email}
</Text>
</HStack>
)}
</VStack>
</TableCell>
<TableCell>{custBookings(row)}</TableCell>
<TableCell>
<HStack justify="end">
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
@@ -344,6 +568,30 @@ const Customers = () => {
</Table> </Table>
</TableScroll> </TableScroll>
)} )}
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} customer${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={CUSTOMERS_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Customers pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

View File

@@ -1,20 +1,35 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { MdWarningAmber, MdAdd, MdCheckCircleOutline } from 'react-icons/md'; import { MdWarningAmber, MdAdd, MdCheckCircleOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Divider } from '@astryxdesign/core/Divider';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { TextArea } from '@astryxdesign/core/TextArea'; import { TextArea } from '@astryxdesign/core/TextArea';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Pagination } from '@astryxdesign/core/Pagination';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
@@ -30,6 +45,29 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand; const BRAND = DT.brand;
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, matching
// orders.js/riders.js/Tenants.js's tables).
const EXCEPTIONS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable']; const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') })); const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') }));
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical']; const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
@@ -51,6 +89,11 @@ const Exceptions = () => {
const [resolveRow, setResolveRow] = useState(null); const [resolveRow, setResolveRow] = useState(null);
const [resolution, setResolution] = useState(''); const [resolution, setResolution] = useState('');
const [resolveStatus, setResolveStatus] = useState('Resolved'); const [resolveStatus, setResolveStatus] = useState('Resolved');
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions }); const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions });
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
@@ -123,6 +166,132 @@ const Exceptions = () => {
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as orders.js's
// / riders.js's / Tenants.js's `columns`.
const columns = useMemo(
() => [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'consignment',
accessorKey: 'consignmentid',
size: 170,
minSize: 130,
meta: { label: 'Consignment' },
header: () => <SectionLabel>Consignment</SectionLabel>,
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
},
{
id: 'hub',
accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'),
size: 150,
minSize: 120,
meta: { label: 'Hub' },
header: () => <SectionLabel>Hub</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text>
},
{
id: 'type',
accessorKey: 'exceptiontype',
size: 150,
minSize: 120,
meta: { label: 'Type' },
header: () => <SectionLabel>Type</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text>
},
{
id: 'severity',
accessorKey: 'severity',
size: 130,
minSize: 110,
meta: { label: 'Severity' },
header: () => <SectionLabel>Severity</SectionLabel>,
cell: ({ getValue }) => <Badge variant={SEVERITY_BADGE[getValue()] || 'neutral'} label={getValue()} />
},
{
id: 'description',
accessorKey: 'description',
size: 260,
minSize: 180,
meta: { label: 'Description' },
header: () => <SectionLabel>Description</SectionLabel>,
cell: ({ getValue }) => (
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={getValue()}>
{getValue()}
</Text>
)
},
{
id: 'status',
accessorFn: (row) => row.status || 'Open',
size: 130,
minSize: 110,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => <Text>{getValue()}</Text>
},
{
id: 'actions',
size: 140,
minSize: 110,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) =>
!['Resolved', 'Closed'].includes(row.original.status) ? (
<HStack justify="end">
<IconButton
label="Resolve / Close"
tooltip="Resolve / Close"
size="sm"
variant="ghost"
icon={<MdCheckCircleOutline size={16} />}
onClick={() => setResolveRow(row.original)}
/>
</HStack>
) : null
}
],
[hubMap, pagination.pageIndex, pagination.pageSize]
);
const table = useTable({
features: EXCEPTIONS_TABLE_FEATURES,
// `rows` is already useMemo'd above (stable reference across renders) —
// required for TanStack's row-model memoization to hold and for
// pagination clicks to stick (an unmemoized array reads as "data
// changed" on every render and auto-resets to page 1).
data: rows,
columns,
getRowId: (row, index) => String(row.exceptionid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />} {(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
@@ -163,25 +332,107 @@ const Exceptions = () => {
</FilterToolbar> </FilterToolbar>
} }
> >
<TableScroll minWidth={1020}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Exceptions"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Consignment</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Hub</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Type</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Severity</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Description</TableHeaderCell> return (
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>Actions</TableHeaderCell> key={header.id}
</TableRow> scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={8} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={8}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No exceptions logged" title="No exceptions logged"
description="Log lost, damaged or misrouted parcels here to track resolution." description="Log lost, damaged or misrouted parcels here to track resolution."
@@ -190,41 +441,49 @@ const Exceptions = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.exceptionid || index}> <TableRow key={tableRow.id}>
<TableCell>{index + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell>{row.consignmentid}</TableCell> <TableCell
<TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell> key={cell.id}
<TableCell>{row.exceptiontype}</TableCell> style={{
<TableCell> textAlign: cell.column.columnDef.meta?.align,
<Badge variant={SEVERITY_BADGE[row.severity] || 'neutral'} label={row.severity} /> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
</TableCell> }}
<TableCell style={{ maxWidth: 220 }}> >
<Text style={{ whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }} title={row.description}> {flexRender(cell.column.columnDef.cell, cell.getContext())}
{row.description} </TableCell>
</Text> ))}
</TableCell>
<TableCell>{row.status || 'Open'}</TableCell>
<TableCell>
{!['Resolved', 'Closed'].includes(row.status) && (
<HStack justify="end">
<IconButton
label="Resolve / Close"
tooltip="Resolve / Close"
size="sm"
variant="ghost"
icon={<MdCheckCircleOutline size={16} />}
onClick={() => setResolveRow(row)}
/>
</HStack>
)}
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} exception${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Exceptions pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

View File

@@ -1,11 +1,36 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace, MdOutlineCheckCircle } from 'react-icons/md'; import {
MdOutlineWarehouse,
MdEdit,
MdDeleteOutline,
MdAdd,
MdPlace,
MdOutlineCheckCircle,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -16,6 +41,7 @@ import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -41,6 +67,29 @@ const STATUS_OPTIONS = [
{ value: 'Inactive', label: 'Inactive' } { value: 'Inactive', label: 'Inactive' }
]; ];
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const HUBS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const HUBS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const emptyForm = { const emptyForm = {
hubid: null, hubid: null,
hubname: '', hubname: '',
@@ -63,6 +112,11 @@ const Hubs = () => {
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// /Tenants.js's slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
@@ -156,6 +210,138 @@ const Hubs = () => {
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
// A search change re-scopes the list, so page 1 is the only page that
// still means anything.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [debouncedSearch]);
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as orders.js's
// /riders.js's/Tenants.js's `columns`.
const columns = useMemo(
() => [
{
id: 'serial',
size: 48,
minSize: 40,
maxSize: 56,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'hubname',
accessorKey: 'hubname',
size: 220,
minSize: 160,
meta: { label: 'Hub Name' },
header: () => <SectionLabel>Hub Name</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
},
{
id: 'hubtype',
accessorFn: (row) => (row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'),
size: 150,
minSize: 120,
meta: { label: 'Type' },
header: () => <SectionLabel>Type</SectionLabel>,
cell: ({ getValue }) => getValue()
},
{
id: 'city',
accessorKey: 'applocationid',
size: 100,
minSize: 80,
meta: { label: 'City' },
header: () => <SectionLabel>City</SectionLabel>,
cell: ({ getValue }) => getValue() ?? '—'
},
{
id: 'address',
accessorKey: 'address',
size: 240,
minSize: 160,
meta: { label: 'Address' },
header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'pincode',
accessorKey: 'pincode',
size: 110,
minSize: 90,
meta: { label: 'Pincode' },
header: () => <SectionLabel>Pincode</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'status',
accessorKey: 'status',
size: 130,
minSize: 110,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => (getValue() ? <StatusBadge status={getValue()} /> : '—')
},
{
id: 'actions',
size: 120,
minSize: 100,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<IconButton
label="Edit hub"
tooltip="Edit hub"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row.original)}
/>
<IconButton
label="Delete hub"
tooltip="Delete hub"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row.original)}
/>
</HStack>
)
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[pagination.pageIndex, pagination.pageSize]
);
const table = useTable({
features: HUBS_TABLE_FEATURES,
data: rows,
columns,
getRowId: (row, index) => String(row.hubid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || deleteMutation.isPending) && <Loader />} {(isLoading || deleteMutation.isPending) && <Loader />}
@@ -196,25 +382,107 @@ const Hubs = () => {
</FilterToolbar> </FilterToolbar>
} }
> >
<TableScroll minWidth={960}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Hubs"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Hub Name</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Type</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>City</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Address</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Pincode</TableHeaderCell> return (
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>Actions</TableHeaderCell> key={header.id}
</TableRow> scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={8} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={8}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No hubs to show" title="No hubs to show"
description="Create a hub to start routing consignments through it." description="Create a hub to start routing consignments through it."
@@ -223,43 +491,49 @@ const Hubs = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.hubid || index}> <TableRow key={tableRow.id}>
<TableCell>{index + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell> <TableCell
<Text weight="bold">{row.hubname}</Text> key={cell.id}
</TableCell> style={{
<TableCell>{row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell>{row.applocationid ?? '—'}</TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell>{row.address || '—'}</TableCell> }}
<TableCell>{row.pincode || '—'}</TableCell> >
<TableCell>{row.status ? <StatusBadge status={row.status} /> : '—'}</TableCell> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<TableCell> </TableCell>
<HStack gap={1} justify="end"> ))}
<IconButton
label="Edit hub"
tooltip="Edit hub"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete hub"
tooltip="Delete hub"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} hub${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={HUBS_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Hubs pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

View File

@@ -2,13 +2,29 @@ import { Fragment, useEffect, useMemo, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useMutation, useQuery } from '@tanstack/react-query'; import { useMutation, useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MdDirectionsBike, MdArrowBack } from 'react-icons/md'; import { MdDirectionsBike, MdArrowBack, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
import { FaMapLocationDot } from 'react-icons/fa6'; import { FaMapLocationDot } from 'react-icons/fa6';
import { HiOutlineArrowLeft } from 'react-icons/hi'; import { HiOutlineArrowLeft } from 'react-icons/hi';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing behave identically across the console's list pages.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -28,7 +44,7 @@ import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider }
import { OpenToast } from 'components/third-party/OpenToast'; import { OpenToast } from 'components/third-party/OpenToast';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport'; import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { STATUS } from 'themes/dt/tokens'; import { DT, STATUS } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives'; import { TableScroll } from 'themes/dt/primitives';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
@@ -36,125 +52,154 @@ dayjs.extend(utc);
const orderTypeBadge = (type) => (type === 'Economy' ? 'green' : type === 'Risky' ? 'error' : 'blue'); const orderTypeBadge = (type) => (type === 'Economy' ? 'green' : type === 'Risky' ? 'error' : 'blue');
// TanStack Table v9 registers features explicitly — sorting/resizing ship
// here like every other converted page. rowPaginationFeature is registered
// too (table.getRowModel() always resolves through getPaginatedRowModel()
// internally), but this page deliberately never surfaces pagination UI —
// see the `pagination` state below for why.
const ORDERS_PREVIEW_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// Mobile-only rendering of the optimised-orders preview. Mirrors the exact same // Mobile-only rendering of the optimised-orders preview. Mirrors the exact same
// fields, chips and tooltips as the desktop table rows — no behaviour added, // fields, chips and tooltips as the desktop table rows — no behaviour added,
// purely a card layout for phones. Renders for both aiMode 1 and normal mode; // purely a card layout for phones. Renders for both aiMode 1 and normal mode;
// the Zone / Rider fields are gated on aiMode just like the table columns. // the Zone / Rider fields are gated on aiMode just like the table columns.
const MobileOrdersList = ({ list, aiMode }) => { // Reads the same sorted `table` row model the desktop table does, so a
if (!list || list.length === 0) { // sort applied before switching to a narrow viewport isn't lost.
const MobileOrdersList = ({ table, aiMode }) => {
const rows = table.getRowModel().rows;
if (!rows || rows.length === 0) {
return <EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />; return <EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />;
} }
return ( return (
<MobileCardList scroll> <MobileCardList scroll>
{list.map((val, index) => ( {rows.map((tableRow, index) => {
<MobileCard const val = tableRow.original;
key={index} return (
header={ <MobileCard
<HStack justify="between" vAlign="center" gap={1}> key={tableRow.id}
<HStack gap={1} vAlign="center" minWidth={0}> header={
<Text weight="bold" color="secondary"> <HStack justify="between" vAlign="center" gap={1}>
#{index + 1} <HStack gap={1} vAlign="center" minWidth={0}>
</Text> <Text weight="bold" color="secondary">
{aiMode == 1 && <Badge variant="blue" label={val.zone_name} />} #{index + 1}
</Text>
{aiMode == 1 && <Badge variant="blue" label={val.zone_name} />}
</HStack>
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} />
</HStack> </HStack>
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} /> }
</HStack> >
} <MobileFieldGrid>
> <MobileField label="Tenant" full>
<MobileFieldGrid> <Tooltip content={val.tenantaddress}>
<MobileField label="Tenant" full>
<Tooltip content={val.tenantaddress}>
<VStack gap={0} padding={0}>
<Text maxLines={1}>{val.tenantname}</Text>
<Text type="supporting" maxLines={1}>
{val.tenantsuburb}
</Text>
<Text type="supporting" maxLines={1}>
{val.applocation}
</Text>
</VStack>
</Tooltip>
</MobileField>
<MobileField label="Order Location" full>
<Tooltip content={val.locationaddress}>
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
</Tooltip>
<Tooltip content="Order Id">
<Text type="supporting" maxLines={1}>
{val.orderid}
</Text>
</Tooltip>
<HStack gap={3}>
<Tooltip content="Ordered date">
<VStack gap={0} padding={0}> <VStack gap={0} padding={0}>
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text> <Text maxLines={1}>{val.tenantname}</Text>
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text> <Text type="supporting" maxLines={1}>
{val.tenantsuburb}
</Text>
<Text type="supporting" maxLines={1}>
{val.applocation}
</Text>
</VStack> </VStack>
</Tooltip> </Tooltip>
-
<Tooltip content="Delivery date">
<VStack gap={0} padding={0}>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
</VStack>
</Tooltip>
</HStack>
</MobileField>
<MobileField label="Pickup">
<VStack gap={0} padding={0}>
<Text type="supporting">{val.pickupcustomer}</Text>
<Text type="supporting">{val.pickupcontactno}</Text>
<Tooltip content={val.pickupaddress}>
<Text type="supporting">{val.pickupsuburb || val.pickupaddress.slice(0, 20)}</Text>
</Tooltip>
</VStack>
</MobileField>
<MobileField label="Delivery">
<VStack gap={0} padding={0}>
<Text type="supporting">{val.deliverycustomer}</Text>
<Text type="supporting">{val.deliverycontactno}</Text>
<Tooltip content={val.deliveryaddress}>
<Text type="supporting">{val.deliverysuburb || val.deliveryaddress.slice(0, 20)}</Text>
</Tooltip>
</VStack>
</MobileField>
{val.ordernotes ? <MobileField label="Notes" value={val.ordernotes} full /> : null}
{aiMode == 1 && (
<MobileField label="Rider" full>
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text>
<Text>ID : {val.userid}</Text>
</MobileField> </MobileField>
)}
<MobileField label="Profit"> <MobileField label="Order Location" full>
<VStack gap={1} padding={0}> <Tooltip content={val.locationaddress}>
<Tooltip content="Charges"> <Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
</Tooltip> </Tooltip>
<Tooltip content="Amount"> <Tooltip content="Order Id">
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} /> <Text type="supporting" maxLines={1}>
{val.orderid}
</Text>
</Tooltip> </Tooltip>
</VStack> <HStack gap={3}>
</MobileField> <Tooltip content="Ordered date">
<VStack gap={0} padding={0}>
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text>
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text>
</VStack>
</Tooltip>
-
<Tooltip content="Delivery date">
<VStack gap={0} padding={0}>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
</VStack>
</Tooltip>
</HStack>
</MobileField>
<MobileField label="KMS"> <MobileField label="Pickup">
<VStack gap={1} padding={0}> <VStack gap={0} padding={0}>
<Tooltip content="KMS"> <Text type="supporting">{val.pickupcustomer}</Text>
<Badge variant="error" label={`${Number(val.kms || 0).toFixed(2)} km`} /> <Text type="supporting">{val.pickupcontactno}</Text>
</Tooltip> <Tooltip content={val.pickupaddress}>
<Tooltip content="Cumulative Kms"> <Text type="supporting">{val.pickupsuburb || val.pickupaddress.slice(0, 20)}</Text>
<Badge variant="green" label={`${val.cumulativekms} km`} /> </Tooltip>
</Tooltip> </VStack>
</VStack> </MobileField>
</MobileField>
</MobileFieldGrid> <MobileField label="Delivery">
</MobileCard> <VStack gap={0} padding={0}>
))} <Text type="supporting">{val.deliverycustomer}</Text>
<Text type="supporting">{val.deliverycontactno}</Text>
<Tooltip content={val.deliveryaddress}>
<Text type="supporting">{val.deliverysuburb || val.deliveryaddress.slice(0, 20)}</Text>
</Tooltip>
</VStack>
</MobileField>
{val.ordernotes ? <MobileField label="Notes" value={val.ordernotes} full /> : null}
{aiMode == 1 && (
<MobileField label="Rider" full>
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text>
<Text>ID : {val.userid}</Text>
</MobileField>
)}
<MobileField label="Profit">
<VStack gap={1} padding={0}>
<Tooltip content="Charges">
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} />
</Tooltip>
<Tooltip content="Amount">
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} />
</Tooltip>
</VStack>
</MobileField>
<MobileField label="KMS">
<VStack gap={1} padding={0}>
<Tooltip content="KMS">
<Badge variant="error" label={`${Number(val.kms || 0).toFixed(2)} km`} />
</Tooltip>
<Tooltip content="Cumulative Kms">
<Badge variant="green" label={`${val.cumulativekms} km`} />
</Tooltip>
</VStack>
</MobileField>
</MobileFieldGrid>
</MobileCard>
);
})}
</MobileCardList> </MobileCardList>
); );
}; };
@@ -167,6 +212,14 @@ const OrdersPreview = () => {
const [payment, setPayment] = useState(null); const [payment, setPayment] = useState(null);
const [finaldeliveryList, setFinalDeliveryList] = useState([]); const [finaldeliveryList, setFinalDeliveryList] = useState([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
// Sorting/resizing state — mirrors orders.js's/riders.js's slices.
// pageSize: Infinity keeps every row on one "page" (no Pagination UI):
// this is a pre-commit REVIEW step for a single solver run's batch, and
// the operator needs to see every affected order before clicking
// Finalise, not just whatever page happens to be showing.
const [sorting, setSorting] = useState([]);
const [pagination] = useState({ pageIndex: 0, pageSize: Infinity });
const [columnSizing, setColumnSizing] = useState({});
const deliverylist = location.state?.deliverylist; const deliverylist = location.state?.deliverylist;
const zoneData = location.state?.zoneData; const zoneData = location.state?.zoneData;
const metaData = location.state?.metaData; const metaData = location.state?.metaData;
@@ -266,106 +319,225 @@ const OrdersPreview = () => {
}); });
}; };
const renderRow = (val, index) => ( // The column model — everything a column IS declared once here instead of
<Fragment key={index}> // a hand-written body row, same shape as orders.js's/riders.js's/
<TableRow> // Tenants.js's `columns`. Zone/Rider are pushed in conditionally, matching
<TableCell> // the exact `aiMode == 1` gate the header row used before.
<Text>{index + 1}</Text> const columns = useMemo(() => {
</TableCell> const base = [
{aiMode == 1 && ( {
<TableCell> id: 'serial',
<Badge variant="blue" label={val.zone_name} /> size: 44,
</TableCell> minSize: 36,
)} maxSize: 52,
<TableCell> enableSorting: false,
<Tooltip content={val.tenantaddress}> enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => <Text>{t.getRowModel().rows.indexOf(row) + 1}</Text>
}
];
if (aiMode == 1) {
base.push({
id: 'zone',
accessorKey: 'zone_name',
size: 110,
minSize: 90,
meta: { label: 'Zone' },
header: () => <SectionLabel>Zone</SectionLabel>,
cell: ({ getValue }) => <Badge variant="blue" label={getValue()} />
});
}
base.push(
{
id: 'tenant',
accessorKey: 'tenantname',
size: 220,
minSize: 170,
meta: { label: 'Tenant' },
header: () => <SectionLabel>Tenant</SectionLabel>,
cell: ({ getValue, row }) => (
<Tooltip content={row.original.tenantaddress}>
<VStack gap={0} padding={0}> <VStack gap={0} padding={0}>
<Text maxLines={1}>{val.tenantname}</Text> <Text maxLines={1}>{getValue()}</Text>
<Text type="supporting" maxLines={1}> <Text type="supporting" maxLines={1}>
{val.tenantsuburb} {row.original.tenantsuburb}
</Text> </Text>
<Text type="supporting" maxLines={1}> <Text type="supporting" maxLines={1}>
{val.applocation} {row.original.applocation}
</Text> </Text>
</VStack> </VStack>
</Tooltip> </Tooltip>
</TableCell> )
<TableCell> },
<Tooltip content={val.locationaddress}> {
<Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text> id: 'orderLocation',
</Tooltip> accessorKey: 'locationname',
<Tooltip content="Order Id"> size: 240,
<Text type="supporting" maxLines={1}> minSize: 190,
{val.orderid} meta: { label: 'Order Location' },
</Text> header: () => <SectionLabel>Order Location</SectionLabel>,
</Tooltip> cell: ({ row }) => {
<HStack gap={3}> const val = row.original;
<Tooltip content="Ordered date"> return (
<VStack gap={0} padding={0}> <>
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text> <Tooltip content={val.locationaddress}>
<Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text> <Text maxLines={1}>{`${val.locationname}-(${val.locationsuburb})`}</Text>
</VStack> </Tooltip>
</Tooltip> <Tooltip content="Order Id">
- <Text type="supporting" maxLines={1}>
<Tooltip content="Delivery date"> {val.orderid}
<VStack gap={0} padding={0}> </Text>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text> </Tooltip>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text> <HStack gap={3}>
</VStack> <Tooltip content="Ordered date">
</Tooltip> <VStack gap={0} padding={0}>
</HStack> <Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')}</Text>
</TableCell> <Text type="supporting">{parseDoormileTimestamp(val.orderdate).format('hh:mm A')}</Text>
<TableCell> </VStack>
</Tooltip>
-
<Tooltip content="Delivery date">
<VStack gap={0} padding={0}>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')}</Text>
<Text type="supporting">{parseDoormileTimestamp(val.deliverydate).format('hh:mm A')}</Text>
</VStack>
</Tooltip>
</HStack>
</>
);
}
},
{
id: 'pickup',
accessorKey: 'pickupcustomer',
size: 200,
minSize: 160,
meta: { label: 'Pickup' },
header: () => <SectionLabel>Pickup</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={0} padding={0}> <VStack gap={0} padding={0}>
<Text type="supporting">{val.pickupcustomer}</Text> <Text type="supporting">{getValue()}</Text>
<Text type="supporting">{val.pickupcontactno}</Text> <Text type="supporting">{row.original.pickupcontactno}</Text>
<Tooltip content={val.pickupaddress}> <Tooltip content={row.original.pickupaddress}>
<Text type="supporting">{val.pickupsuburb || val.pickupaddress.slice(0, 20)}</Text> <Text type="supporting">{row.original.pickupsuburb || row.original.pickupaddress.slice(0, 20)}</Text>
</Tooltip> </Tooltip>
</VStack> </VStack>
</TableCell> )
<TableCell> },
{
id: 'delivery',
accessorKey: 'deliverycustomer',
size: 200,
minSize: 160,
meta: { label: 'Delivery' },
header: () => <SectionLabel>Delivery</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={0} padding={0}> <VStack gap={0} padding={0}>
<Text type="supporting">{val.deliverycustomer}</Text> <Text type="supporting">{getValue()}</Text>
<Text type="supporting">{val.deliverycontactno}</Text> <Text type="supporting">{row.original.deliverycontactno}</Text>
<Tooltip content={val.deliveryaddress}> <Tooltip content={row.original.deliveryaddress}>
<Text type="supporting">{val.deliverysuburb || val.deliveryaddress.slice(0, 20)}</Text> <Text type="supporting">{row.original.deliverysuburb || row.original.deliveryaddress.slice(0, 20)}</Text>
</Tooltip> </Tooltip>
</VStack> </VStack>
</TableCell> )
<TableCell>{val.ordernotes}</TableCell> },
{aiMode == 1 && ( {
<TableCell> id: 'notes',
<Text style={{ whiteSpace: 'nowrap' }}>{val.username}</Text> accessorKey: 'ordernotes',
<Text>ID : {val.userid}</Text> size: 160,
</TableCell> minSize: 120,
)} meta: { label: 'Notes' },
<TableCell style={{ textAlign: 'center' }}> header: () => <SectionLabel>Notes</SectionLabel>,
<Badge variant={orderTypeBadge(val.ordertype)} label={val.ordertype} /> cell: ({ getValue }) => <Text>{getValue()}</Text>
</TableCell> }
<TableCell style={{ textAlign: 'center' }}> );
if (aiMode == 1) {
base.push({
id: 'rider',
accessorKey: 'username',
size: 150,
minSize: 120,
meta: { label: 'Rider' },
header: () => <SectionLabel>Rider</SectionLabel>,
cell: ({ getValue, row }) => (
<>
<Text style={{ whiteSpace: 'nowrap' }}>{getValue()}</Text>
<Text>ID : {row.original.userid}</Text>
</>
)
});
}
base.push(
{
id: 'type',
accessorKey: 'ordertype',
size: 110,
minSize: 90,
meta: { label: 'Type', align: 'center' },
header: () => <SectionLabel>Type</SectionLabel>,
cell: ({ getValue }) => <Badge variant={orderTypeBadge(getValue())} label={getValue()} />
},
{
id: 'profit',
accessorKey: 'deliveryamt',
size: 150,
minSize: 120,
meta: { label: 'Profit', align: 'center' },
header: () => <SectionLabel>Profit</SectionLabel>,
cell: ({ row }) => (
<VStack gap={1} padding={0} vAlign="center"> <VStack gap={1} padding={0} vAlign="center">
<Tooltip content="Charges"> <Tooltip content="Charges">
<Badge variant="error" label={`₹ ${val.deliverycharge.toFixed(2)} `} /> <Badge variant="error" label={`₹ ${row.original.deliverycharge.toFixed(2)} `} />
</Tooltip> </Tooltip>
<Tooltip content="Amount"> <Tooltip content="Amount">
<Badge variant="green" label={`₹ ${val.deliveryamt.toFixed(2)} `} /> <Badge variant="green" label={`₹ ${row.original.deliveryamt.toFixed(2)} `} />
</Tooltip> </Tooltip>
</VStack> </VStack>
</TableCell> )
<TableCell style={{ textAlign: 'center' }}> },
{
id: 'kms',
accessorKey: 'kms',
size: 150,
minSize: 120,
meta: { label: 'Kms', align: 'center' },
header: () => <SectionLabel>Kms</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={1} padding={0} vAlign="center"> <VStack gap={1} padding={0} vAlign="center">
<Tooltip content="KMS"> <Tooltip content="KMS">
<Badge variant="error" label={`${Number(val.kms || 0).toFixed(2)} km`} /> <Badge variant="error" label={`${Number(getValue() || 0).toFixed(2)} km`} />
</Tooltip> </Tooltip>
<Tooltip content="Cumulative Kms"> <Tooltip content="Cumulative Kms">
<Badge variant="green" label={`${val.cumulativekms} km`} /> <Badge variant="green" label={`${row.original.cumulativekms} km`} />
</Tooltip> </Tooltip>
</VStack> </VStack>
</TableCell> )
</TableRow> }
</Fragment> );
);
return base;
}, [aiMode]);
const table = useTable({
features: ORDERS_PREVIEW_TABLE_FEATURES,
// finaldeliveryList is already component state (stable reference between
// renders unless setFinalDeliveryList is called) — required for
// TanStack's row-model memoization to hold correctly.
data: finaldeliveryList,
columns,
getRowId: (row, index) => String(row.orderid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onColumnSizingChange: setColumnSizing
});
const colCount = columns.length;
return ( return (
<> <>
@@ -405,34 +577,129 @@ const OrdersPreview = () => {
<DataCard> <DataCard>
{isMobile ? ( {isMobile ? (
<MobileOrdersList list={finaldeliveryList} aiMode={aiMode} /> <MobileOrdersList table={table} aiMode={aiMode} />
) : ( ) : (
<TableScroll minWidth={aiMode == 1 ? 1400 : 1200}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Orders preview"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
{aiMode == 1 && <TableHeaderCell>Zone</TableHeaderCell>} {headerGroup.headers.map((header) => {
<TableHeaderCell>Tenant</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Order Location</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Pickup</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Delivery</TableHeaderCell> return (
<TableHeaderCell>Notes</TableHeaderCell> <TableHeaderCell
{aiMode == 1 && <TableHeaderCell>Rider</TableHeaderCell>} key={header.id}
<TableHeaderCell style={{ textAlign: 'center' }}>Type</TableHeaderCell> scope="col"
<TableHeaderCell style={{ textAlign: 'center' }}>Profit</TableHeaderCell> aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
<TableHeaderCell style={{ textAlign: 'center' }}>KMS</TableHeaderCell> style={{
</TableRow> width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{finaldeliveryList?.length == 0 && ( {finaldeliveryList?.length == 0 && (
<TableRow> <TableRow>
<TableCell colSpan={10}> <TableCell colSpan={colCount}>
<EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} /> <EmptyState title="No orders to preview" icon={<MdDirectionsBike size={28} />} />
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{finaldeliveryList?.map((val, index) => renderRow(val, index))} {table.getRowModel().rows.map((tableRow) => (
<Fragment key={tableRow.id}>
<TableRow>
{tableRow.getAllCells().map((cell) => (
<TableCell
key={cell.id}
style={{
textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
</Fragment>
))}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>

View File

@@ -13,6 +13,7 @@ import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -27,13 +28,28 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { MdOutlineCloudUpload, MdDeleteOutline } from 'react-icons/md'; // TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing behave identically across the console. No pagination
// feature registered here deliberately — see the comment above dropColumns.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature
} from '@tanstack/react-table';
import { MdOutlineCloudUpload, MdDeleteOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md';
import { FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt, FaTruck } from 'react-icons/fa'; import { FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt, FaTruck } from 'react-icons/fa';
import { FaLocationDot } from 'react-icons/fa6'; import { FaLocationDot } from 'react-icons/fa6';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import { PageShell } from 'components/nearle_components/PageLayout'; import { PageShell } from 'components/nearle_components/PageLayout';
import { DT } from 'themes/dt/tokens';
import { TableScroll } from 'themes/dt/primitives'; import { TableScroll } from 'themes/dt/primitives';
import './OrdersRedesign.css'; import './OrdersRedesign.css';
@@ -110,6 +126,100 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => (
</div> </div>
); );
// TanStack Table v9 registers features explicitly — sorting/resizing only,
// no pagination (see the comment above dropColumns for why) and no
// row-selection/column-visibility (neither table needs them).
const MULTI_ORDERS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature
});
// Uppercase micro-label for the tables' column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// File Preview table's columns — purely read-only display, no closures over
// component state, so this is safe to declare once at module scope rather
// than inside the component.
const previewColumns = [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => t.getRowModel().rows.indexOf(row) + 1
},
{
id: 'name',
accessorKey: 'firstname',
size: 170,
minSize: 130,
meta: { label: 'Name' },
header: () => <SectionLabel>Name</SectionLabel>,
cell: ({ getValue }) => (
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue() || '—'}
</Text>
)
},
{
id: 'contact',
accessorKey: 'contactno',
size: 140,
minSize: 110,
meta: { label: 'Contact' },
header: () => <SectionLabel>Contact</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'address',
accessorKey: 'address',
size: 260,
minSize: 180,
meta: { label: 'Address' },
header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ getValue }) => (
<Tooltip content={getValue() || ''}>
<Text maxLines={1} style={{ maxWidth: 240 }}>
{getValue() || '—'}
</Text>
</Tooltip>
)
},
{
id: 'qty',
accessorKey: 'quantity',
size: 90,
minSize: 70,
meta: { label: 'Qty', align: 'center' },
header: () => <SectionLabel>Qty</SectionLabel>,
cell: ({ getValue }) => getValue() ?? '—'
},
{
id: 'cash',
accessorKey: 'collectionamt',
size: 120,
minSize: 100,
meta: { label: 'Cash', align: 'center' },
header: () => <SectionLabel>Cash</SectionLabel>,
cell: ({ getValue }) => {
const v = getValue();
return v != null ? `₹${Number(v).toFixed(2)}` : '—';
}
}
];
const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => ( const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
<div <div
role="button" role="button"
@@ -213,6 +323,14 @@ const MultipleOrders = () => {
const [users, setUsers] = useState([]); const [users, setUsers] = useState([]);
const [fileName, setFileName] = useState(''); const [fileName, setFileName] = useState('');
// Table sorting/resizing state — mirrors orders.js's/riders.js's slices.
// Two independent table instances (Drop List, File Preview), each with its
// own state.
const [dropSorting, setDropSorting] = useState([]);
const [dropColumnSizing, setDropColumnSizing] = useState({});
const [previewSorting, setPreviewSorting] = useState([]);
const [previewColumnSizing, setPreviewColumnSizing] = useState({});
const toastCacheRef = useRef({}); const toastCacheRef = useRef({});
// ============================== toast ============================== // ============================== toast ==============================
@@ -749,6 +867,179 @@ const MultipleOrders = () => {
} }
}; };
// Drop List's column model — deliberately NOT wrapped in useMemo: its
// Address/Qty/Cash cells close over uploadType, pickCust and several
// setDropCust-based handlers that change often, and every one of them is
// already recreated fresh each render in this file (none are themselves
// memoized) — memoizing here would need an unmanageable dependency list
// for no benefit, matching the same call made on Tenants.js's Actions
// column. Sorting/resizing are display-only — this table never paginates,
// since the operator has to review and complete EVERY row's address
// before Dispatch is enabled (an incomplete row blocks submit with a
// toast), and hiding some of those rows behind a page-2 would work
// against that. Submission (`createorders`) always reads the full
// `dropCust` array regardless of what's sorted/visible.
const dropColumns = [
{
id: 'serial',
size: 40,
minSize: 34,
maxSize: 48,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => t.getRowModel().rows.indexOf(row) + 1
},
{
id: 'customer',
accessorKey: 'firstname',
size: 160,
minSize: 120,
meta: { label: 'Customer' },
header: () => <SectionLabel>Customer</SectionLabel>,
cell: ({ getValue }) => (
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
)
},
{
id: 'address',
size: 260,
minSize: 200,
enableSorting: false,
meta: { label: 'Address' },
header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ row }) => {
const customer = row.original;
return customer.latitude == null || customer.longitude == null ? (
<AddressAutocomplete
id={`drop-address-${customer._rowkey}`}
placeholder="Search address…"
value={customer.address || ''}
onChange={(text) => setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c)))}
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)}
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined}
/>
) : (
<Tooltip content={customer.address || ''}>
<Text maxLines={1} style={{ maxWidth: 260 }}>
{customer.address}
</Text>
</Tooltip>
);
}
},
{
id: 'qty',
size: 110,
minSize: 90,
enableSorting: false,
meta: { label: 'Qty', align: 'center' },
header: () => <SectionLabel>Qty</SectionLabel>,
cell: ({ row }) => {
const customer = row.original;
return uploadType === 0 ? (
customer.quantity
) : (
<NumberInput
label="Quantity"
isLabelHidden
value={customer.quantity || undefined}
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
size="sm"
/>
);
}
},
{
id: 'cash',
size: 130,
minSize: 100,
enableSorting: false,
meta: { label: 'Cash', align: 'center' },
header: () => <SectionLabel>Cash</SectionLabel>,
cell: ({ row }) => {
const customer = row.original;
return uploadType === 0 ? (
`₹${Number(customer.collectionamt || 0).toFixed(2)}`
) : (
<NumberInput
label="Collection amount"
isLabelHidden
value={customer.collectionamt || undefined}
onChange={(v) => handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)}
size="sm"
/>
);
}
},
{
id: 'km',
accessorKey: 'distance',
size: 90,
minSize: 70,
meta: { label: 'Km' },
header: () => <SectionLabel>Km</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'charge',
accessorFn: (row) => Number(row.totalcharge) || 0,
size: 120,
minSize: 100,
meta: { label: 'Charge', align: 'right' },
header: () => <SectionLabel>Charge</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">₹{getValue().toFixed(2)}</Text>
},
{
id: 'action',
size: 56,
minSize: 48,
maxSize: 64,
enableSorting: false,
enableResizing: false,
meta: { label: '' },
header: () => null,
cell: ({ row }) => {
const customer = row.original;
return (
<IconButton
label="Remove"
tooltip="Remove"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={14} />}
onClick={() => handleCheckboxChange1(customer)}
/>
);
}
}
];
const dropTable = useTable({
features: MULTI_ORDERS_TABLE_FEATURES,
data: dropCust,
columns: dropColumns,
getRowId: (row, index) => row._rowkey ?? `row-${index}`,
columnResizeMode: 'onChange',
state: { sorting: dropSorting, columnSizing: dropColumnSizing },
onSortingChange: setDropSorting,
onColumnSizingChange: setDropColumnSizing
});
const previewTable = useTable({
features: MULTI_ORDERS_TABLE_FEATURES,
data: users,
columns: previewColumns,
getRowId: (row, index) => row._rowkey ?? `row-${index}`,
columnResizeMode: 'onChange',
state: { sorting: previewSorting, columnSizing: previewColumnSizing },
onSortingChange: setPreviewSorting,
onColumnSizingChange: setPreviewColumnSizing
});
return ( return (
<> <>
{(loading || btnLoading) && <Loader />} {(loading || btnLoading) && <Loader />}
@@ -1043,91 +1334,116 @@ const MultipleOrders = () => {
<div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}> <div style={{ flex: 1, minHeight: 0, overflow: 'auto' }}>
{previewMode === 'drops' && ( {previewMode === 'drops' && (
<TableScroll minWidth={720}> <TableScroll minWidth={dropTable.getTotalSize()}>
<Table density="compact" dividers="rows" hasHover> <Table
density="compact"
dividers="rows"
hasHover
style={{ tableLayout: 'fixed', width: '100%', minWidth: dropTable.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {dropTable.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Customer</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Address</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Km</TableHeaderCell> return (
<TableHeaderCell style={{ textAlign: 'right' }}>Charge</TableHeaderCell> <TableHeaderCell
<TableHeaderCell></TableHeaderCell> key={header.id}
</TableRow> scope="col"
</TableHeader> aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
<TableBody> style={{
{dropCust.map((customer, index) => ( width: header.getSize(),
<TableRow key={customer._rowkey || index}> minWidth: header.column.columnDef.minSize,
<TableCell>{index + 1}</TableCell> maxWidth: header.column.columnDef.maxSize || header.getSize(),
<TableCell> textAlign: align,
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}> position: 'relative'
{customer.firstname} }}
</Text> >
</TableCell> <HStack
<TableCell style={{ maxWidth: 260 }}> gap={1}
{customer.latitude == null || customer.longitude == null ? ( vAlign="center"
<AddressAutocomplete justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
id={`drop-address-${customer._rowkey}`} role={canSort ? 'button' : undefined}
placeholder="Search address…" tabIndex={canSort ? 0 : undefined}
value={customer.address || ''} onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onChange={(text) => onKeyDown={
setDropCust((prev) => prev.map((c) => (c._rowkey === customer._rowkey ? { ...c, address: text } : c))) canSort
} ? (e) => {
onPlaceSelected={(place) => handleRowPlaceSelected(customer._rowkey, place)} if (e.key === 'Enter' || e.key === ' ') {
bias={pickCust?.latitude ? { lat: pickCust.latitude, lng: pickCust.longitude } : undefined} e.preventDefault();
/> header.column.toggleSorting();
) : ( }
<Tooltip content={customer.address || ''}> }
<Text maxLines={1} style={{ maxWidth: 260 }}> : undefined
{customer.address} }
</Text> style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
</Tooltip> >
)} {flexRender(header.column.columnDef.header, header.getContext())}
</TableCell> {canSort &&
<TableCell style={{ textAlign: 'center' }}> (sorted === 'asc' ? (
{uploadType === 0 ? ( <MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
customer.quantity ) : sorted === 'desc' ? (
) : ( <MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
<NumberInput ) : (
label="Quantity" <MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
isLabelHidden ))}
value={customer.quantity || undefined} </HStack>
onChange={(v) => handleQuantityChange(customer._rowkey, v ?? 0)}
size="sm" {header.column.getCanResize() && (
/> <Center
)} aria-hidden="true"
</TableCell> onMouseDown={header.getResizeHandler()}
<TableCell style={{ textAlign: 'center' }}> onTouchStart={header.getResizeHandler()}
{uploadType === 0 ? ( style={{
`₹${Number(customer.collectionamt || 0).toFixed(2)}` position: 'absolute',
) : ( top: 0,
<NumberInput right: 0,
label="Collection amount" height: '100%',
isLabelHidden width: 10,
value={customer.collectionamt || undefined} cursor: 'col-resize',
onChange={(v) => handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)} userSelect: 'none',
size="sm" touchAction: 'none'
/> }}
)} >
</TableCell> <Center
<TableCell>{customer.distance || '—'}</TableCell> style={{
<TableCell style={{ textAlign: 'right' }}> width: 2,
<Text weight="bold">₹{Number(customer?.totalcharge || 0).toFixed(2)}</Text> height: '60%',
</TableCell> borderRadius: 'var(--radius-element)',
<TableCell> backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
<IconButton transition: 'background-color 120ms ease'
label="Remove" }}
tooltip="Remove" />
size="sm" </Center>
variant="destructive" )}
icon={<MdDeleteOutline size={14} />} </TableHeaderCell>
onClick={() => handleCheckboxChange1(customer)} );
/> })}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableHeader>
<TableBody>
{dropTable.getRowModel().rows.map((tableRow) => (
<TableRow key={tableRow.id}>
{tableRow.getAllCells().map((cell) => (
<TableCell
key={cell.id}
style={{
textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))}
{/* Summary row, not a data row — deliberately hand-coded
rather than run through the column model, and
always sums the FULL dropCust array (not affected
by sorting). */}
<TableRow> <TableRow>
<TableCell> <TableCell>
<Text weight="bold">Total</Text> <Text weight="bold">Total</Text>
@@ -1155,39 +1471,110 @@ const MultipleOrders = () => {
)} )}
{previewMode === 'preview' && ( {previewMode === 'preview' && (
<TableScroll minWidth={640}> <TableScroll minWidth={previewTable.getTotalSize()}>
<Table density="compact" dividers="rows" hasHover> <Table
density="compact"
dividers="rows"
hasHover
style={{ tableLayout: 'fixed', width: '100%', minWidth: previewTable.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {previewTable.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Name</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Contact</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Address</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell style={{ textAlign: 'center' }}>Qty</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell style={{ textAlign: 'center' }}>Cash</TableHeaderCell> return (
</TableRow> <TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{users.map((u, i) => ( {previewTable.getRowModel().rows.map((tableRow) => (
<TableRow key={i}> <TableRow key={tableRow.id}>
<TableCell>{i + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell> <TableCell
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}> key={cell.id}
{u.firstname || '—'} style={{
</Text> textAlign: cell.column.columnDef.meta?.align,
</TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell>{u.contactno || '—'}</TableCell> }}
<TableCell style={{ maxWidth: 240 }}> >
<Tooltip content={u.address || ''}> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<Text maxLines={1} style={{ maxWidth: 240 }}> </TableCell>
{u.address || '—'} ))}
</Text>
</Tooltip>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>{u.quantity ?? '—'}</TableCell>
<TableCell style={{ textAlign: 'center' }}>
{u.collectionamt != null ? `₹${Number(u.collectionamt).toFixed(2)}` : '—'}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>

View File

@@ -63,8 +63,9 @@ import StatusBadge from 'components/nearle_components/StatusBadge';
import StatusTabs from 'components/nearle_components/StatusTabs'; import StatusTabs from 'components/nearle_components/StatusTabs';
import TableLoader from 'components/nearle_components/TableLoader'; import TableLoader from 'components/nearle_components/TableLoader';
import PageHeader from 'components/nearle_components/PageHeader'; import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import { PageShell, DataCard } from 'components/nearle_components/PageLayout'; import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import { useQuery, useMutation } from '@tanstack/react-query'; import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api'; import { fetchPercentageData, createAutomationDeliveries, getallriders, buildMilerLookup, notifyRider } from '../../api/api';
@@ -909,6 +910,25 @@ const Orders = () => {
<PageShell fill> <PageShell fill>
<PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live /> <PageHeader title="Orders" subtitle={`Live · ${locoName} · ${datestatus}`} live />
{/* Derived from the SAME statusCounts map the tab badges below read,
by mapping over ORDERS_STATUS_TABS rather than a second parallel
grouping — so this row can never disagree with the tabs. */}
<KpiGrid>
{ORDERS_STATUS_TABS.map((t) => {
const Icon = t.icon;
return (
<StatCard
key={t.status}
title={t.label}
value={t.statuses.reduce((sum, s) => sum + (statusCounts[s] ?? 0), 0)}
icon={<Icon size={20} />}
color={t.color}
loading={isLoadingGetOrders}
/>
);
})}
</KpiGrid>
<DataCard <DataCard
fill fill
tabs={ tabs={

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 { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
@@ -15,15 +15,34 @@ import {
MdOutlineLocalShipping, MdOutlineLocalShipping,
MdOutlineCheckCircle, MdOutlineCheckCircle,
MdOutlinePendingActions, MdOutlinePendingActions,
MdOutlineCancel MdOutlineCancel,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md'; } from 'react-icons/md';
import { FaCircleCheck } from 'react-icons/fa6'; import { FaCircleCheck } from 'react-icons/fa6';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
dayjs.extend(utc); dayjs.extend(utc);
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -34,6 +53,7 @@ import { Selector } from '@astryxdesign/core/Selector';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import MapWithRoute from './mapWithRoute'; import MapWithRoute from './mapWithRoute';
@@ -119,14 +139,43 @@ const StampCell = ({ value, formatDate, formatTime, success }) => {
); );
}; };
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const ORDERS_DETAILS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// ==============================|| Orders Details ||============================== // // ==============================|| Orders Details ||============================== //
export default function OrdersDetails() { export default function OrdersDetails() {
const isMobile = useIsNarrowViewport(); const isMobile = useIsNarrowViewport();
const loadMoreRef = useRef();
const userid = localStorage.getItem('userid'); const userid = localStorage.getItem('userid');
const [page, setPage] = useState(0); // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
const [rowsPerPage, setRowsPerPage] = useState(50); // slices. Replaces the old page/rowsPerPage pair, which was never actually
// wired to real pagination (the query key hardcodes 200 rows/page server-
// side and auto-drains every page unconditionally — see the fetchDeliveries
// useInfiniteQuery below); page/rowsPerPage only fed the "#" serial number,
// always at page 0.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 50 });
const [columnSizing, setColumnSizing] = useState({});
const [locaName, setLocoName] = useState('All'); const [locaName, setLocoName] = useState('All');
const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD')); const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD')); const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD'));
@@ -297,36 +346,13 @@ export default function OrdersDetails() {
const deliveredLenght = statusCountByKey.delivered; const deliveredLenght = statusCountByKey.delivered;
const cancelLenght = statusCountByKey.cancelled; const cancelLenght = statusCountByKey.cancelled;
useEffect(() => { // The scroll-to-reveal sentinel + IntersectionObserver that used to live
if (!hasNextPage) return; // here was already dead: the unconditional drain effect above (`if
const observer = new IntersectionObserver( // (hasNextPage && !isFetchingNextPage) fetchNextPage()`) exhausts every
(entries) => { // page as soon as data arrives, regardless of scroll position, so
if (entries[0].isIntersecting) { // `hasNextPage` is almost always already false by the time a user could
fetchNextPage(); // scroll to the sentinel. Real client-side pagination (via the `table`
} // instance below) replaces it instead.
},
{
// The sentinel lives AFTER </TableScroll>, not inside it. An
// IntersectionObserver only reports on targets that are descendants of
// its root, so rooting this at `.dt-table-scroll` meant the callback
// never fired on desktop and the report stopped at page 1. (It happened
// to work on mobile, where MobileCardList renders instead and the
// querySelector returned null — falling back to the viewport.)
//
// The page scrolls inside AppShell's own container, not the window,
// because MainLayout mounts AppShell with height="fill" — so
// `.astryx-layout-content` is the real scroll root. Same target
// orders.js uses; see the comment in layout/MainLayout/index.js.
root: document.querySelector('.astryx-layout-content'),
rootMargin: '200px',
threshold: 0
}
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => {
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
};
}, [hasNextPage, fetchNextPage]);
// ==============================|| Tenant / Location / Rider lookups ||============================== // // ==============================|| Tenant / Location / Rider lookups ||============================== //
@@ -457,6 +483,349 @@ export default function OrdersDetails() {
{ key: 'cancelled', label: 'Cancelled', color: STATUS.cancelled, icon: MdOutlineCancel, value: cancelLenght } { key: 'cancelled', label: 'Cancelled', color: STATUS.cancelled, icon: MdOutlineCancel, value: cancelLenght }
]; ];
// A tab/zone/tenant/location/rider/date/search change re-scopes the list,
// so page 1 is the only page that still means anything — same reset
// orders.js/riders.js do on their own tables.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [currentStatus, appId, tenantid, locationid, selectedRider, startdate, enddate, debouncedSearch]);
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as
// orders.js's/riders.js's/Tenants.js's `columns`. Cell JSX below is a
// straight port of what each column already rendered — only the wiring
// changed (columns own their cell now, instead of a shared body-row map).
const columns = useMemo(
() => [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'map',
size: 60,
minSize: 50,
enableSorting: false,
enableResizing: false,
meta: { label: 'Map' },
header: () => <SectionLabel>Map</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
const isDelivered = r.orderstatus === 'delivered';
return (
<IconButton
label={isDelivered ? 'View rider route' : 'Available for delivered orders'}
tooltip={isDelivered ? 'View rider route' : 'Available for delivered orders'}
size="sm"
variant="ghost"
icon={<MdMap size={14} />}
isDisabled={!isDelivered}
onClick={() => {
if (isDelivered) {
getdeliverylogs(r.deliveryid);
setMapTenant(r);
}
}}
/>
);
}
},
{
id: 'client',
accessorKey: 'tenantname',
size: 200,
minSize: 170,
meta: { label: 'Client' },
header: () => <SectionLabel>Client</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={1.5} vAlign="start">
<AccentAvatar color={BRAND} size={32}>
<MdGroups size={16} />
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Tooltip content="Order ID">
<Text type="supporting" color="secondary" weight="bold">
#{row.original.orderid}
</Text>
</Tooltip>
<Text type="supporting" color="secondary">
{parseDoormileTimestamp(row.original.deliverydate).format('DD/MM/YYYY · hh:mm A')}
</Text>
</VStack>
</HStack>
)
},
{
id: 'pickup',
accessorKey: 'pickupcustomer',
size: 220,
minSize: 180,
meta: { label: 'Pickup' },
header: () => <SectionLabel>Pickup</SectionLabel>,
cell: ({ getValue, row }) => {
const r = row.original;
return (
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue() || '—'}
</Text>
<Text type="supporting" color="secondary">
{r.pickupcontactno}
</Text>
<Tooltip content={r.Pickupaddress || r.pickupsuburb || ''}>
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
{r.pickupsuburb || (r.Pickupaddress ? r.Pickupaddress.slice(0, 22) + '…' : '')}
</Text>
</Tooltip>
{r.applocation && <Badge variant="green" icon={<MdPlace size={11} />} label={r.applocation} />}
</VStack>
);
}
},
{
id: 'drop',
accessorKey: 'deliverycustomer',
size: 220,
minSize: 180,
meta: { label: 'Drop' },
header: () => <SectionLabel>Drop</SectionLabel>,
cell: ({ getValue, row }) => {
const r = row.original;
return (
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue() || '—'}
</Text>
<Text type="supporting" color="secondary">
{r.deliverycontactno}
</Text>
<Tooltip content={r.deliveryaddress || ''}>
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
{r.deliverysuburb || (r.deliveryaddress ? r.deliveryaddress.slice(0, 22) + '…' : '')}
</Text>
</Tooltip>
</VStack>
);
}
},
{
id: 'statusRider',
accessorKey: 'orderstatus',
size: 160,
minSize: 130,
meta: { label: 'Status / Rider' },
header: () => <SectionLabel>Status / Rider</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
return (
<VStack gap={1} padding={0}>
<StatusBadge status={r.orderstatus} />
{r.ridername && (
<HStack gap={1} vAlign="center">
<MdDirectionsBike size={12} color={DT.textMuted} />
<Tooltip content={`Rider ID: ${r.userid}`}>
<Text weight="bold" style={{ whiteSpace: 'nowrap' }}>
{r.ridername}
</Text>
</Tooltip>
</HStack>
)}
</VStack>
);
}
},
{
id: 'assigned',
accessorKey: 'assigntime',
size: 130,
minSize: 110,
meta: { label: 'Assigned' },
header: () => <SectionLabel>Assigned</SectionLabel>,
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'accepted',
accessorKey: 'acceptedtime',
size: 130,
minSize: 110,
meta: { label: 'Accepted' },
header: () => <SectionLabel>Accepted</SectionLabel>,
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'arrived',
accessorKey: 'arrivaltime',
size: 130,
minSize: 110,
meta: { label: 'Arrived' },
header: () => <SectionLabel>Arrived</SectionLabel>,
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'picked',
accessorKey: 'pickuptime',
size: 130,
minSize: 110,
meta: { label: 'Picked' },
header: () => <SectionLabel>Picked</SectionLabel>,
// GET /admin/bookings has no per-event timestamps (accepted/arrived/
// pickup/start/cancel) — only assigntime (updatedat) and deliverydate
// exist on the mapped row (see fetchDeliveries in api.js), so
// row.pickuptime is always undefined and this cell always renders the
// muted dash. The old `success` check compared against a
// `deliverystatus` field that never existed on this row either (the
// real field is `orderstatus`) — dropped rather than guessed, since
// orderstatus's confirmed values have no distinct "picked" state.
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'active',
accessorKey: 'starttime',
size: 130,
minSize: 110,
meta: { label: 'Active' },
header: () => <SectionLabel>Active</SectionLabel>,
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'delivered',
accessorKey: 'deliverytime',
size: 130,
minSize: 110,
meta: { label: 'Delivered' },
header: () => <SectionLabel>Delivered</SectionLabel>,
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'cancelled',
accessorKey: 'canceltime',
size: 130,
minSize: 110,
meta: { label: 'Cancelled' },
header: () => <SectionLabel>Cancelled</SectionLabel>,
cell: ({ getValue }) => <StampCell value={getValue()} formatDate={formatDate} formatTime={formatTime} />
},
{
id: 'notes',
size: 180,
minSize: 140,
maxSize: 220,
enableSorting: false,
meta: { label: 'Notes' },
header: () => <SectionLabel>Notes</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
return r.ordernotes ? (
<HStack gap={1} vAlign="start">
<MdNoteAlt size={12} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
<Text type="supporting" color="secondary" maxLines={2}>
{r.ordernotes}
</Text>
</HStack>
) : (
<Text type="supporting" color="secondary">
—
</Text>
);
}
},
{
id: 'kms',
accessorKey: 'kms',
size: 150,
minSize: 130,
meta: { label: 'Kms', align: 'center' },
header: () => <SectionLabel>Kms</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
const cancelled = r.orderstatus === 'cancelled';
return (
<VStack gap={1} padding={0} vAlign="center">
<MetricPill
variant="red"
icon={<MdStraighten size={11} />}
label={cancelled || r.kms == '' ? '0.00 km' : `${formatKm(r.kms)} km`}
tooltip="KMS"
/>
<MetricPill
variant="green"
icon={<MdStraighten size={11} />}
label={`${formatKm(r.cumulativekms)} km`}
tooltip="Actual KMS"
/>
<MetricPill
variant="blue"
icon={<MdStraighten size={11} />}
label={`${formatKm(r.previouskms || (cancelled ? 0 : r.kms))} km`}
tooltip="Rider KMS"
/>
</VStack>
);
}
},
{
id: 'charges',
accessorKey: 'deliverycharges',
size: 150,
minSize: 130,
meta: { label: 'Charges', align: 'center' },
header: () => <SectionLabel>Charges</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
const cancelled = r.orderstatus === 'cancelled';
return (
<VStack gap={1} padding={0} vAlign="center">
<MetricPill
variant="red"
icon={<MdCurrencyRupee size={11} />}
label={cancelled || r.deliverycharges == '' ? `0.00` : `${r.deliverycharges}.00`}
tooltip="Delivery Charge"
/>
<MetricPill
variant="green"
icon={<MdCurrencyRupee size={11} />}
label={r.deliveryamt == '' ? `0.00` : `${r.deliveryamt}.00`}
tooltip="Delivery Amount"
/>
</VStack>
);
}
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[pagination.pageIndex, pagination.pageSize]
);
const table = useTable({
features: ORDERS_DETAILS_TABLE_FEATURES,
// rows is already useMemo'd above (stable reference) — required for
// pagination clicks to stick, see riders.js's fix for the same bug.
data: rows,
columns,
getRowId: (row, index) => String(row.deliveryid ?? `${row.orderid}-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
const colCount = columns.length;
return ( return (
<> <>
{(fetchDeliveriesIsLoading || {(fetchDeliveriesIsLoading ||
@@ -470,7 +839,7 @@ export default function OrdersDetails() {
Export. They used to be split across the header and a separate filter Export. They used to be split across the header and a separate filter
box below, which is why Export kept wrapping onto its own line and box below, which is why Export kept wrapping onto its own line and
left that box half empty. */} left that box half empty. */}
<PageShell> <PageShell fill>
{/* Only the primary action lives in the header. The zone picker and {/* Only the primary action lives in the header. The zone picker and
date range were up here too and the zone picker OVERLAPPED the date date range were up here too and the zone picker OVERLAPPED the date
button — LocationAutocomplete brings its own width and ignores the button — LocationAutocomplete brings its own width and ignores the
@@ -481,17 +850,7 @@ export default function OrdersDetails() {
title="Orders Details" title="Orders Details"
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`} subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
live live
action={ action={<Button label="Export" variant="primary" icon={<MdFileDownload size={16} />} onClick={() => setReportDialog(true)} />}
<Button
label="Export"
variant="primary"
icon={<MdFileDownload size={16} />}
onClick={() => {
setReportDialog(true);
setTimeout(() => setRowsPerPage(''), 0);
}}
/>
}
/> />
<KpiGrid> <KpiGrid>
@@ -512,13 +871,14 @@ export default function OrdersDetails() {
{/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */} {/* Toolbar: Zone · Tenant · Location · Rider · Date · Search. */}
<DataCard <DataCard
fill
toolbar={ toolbar={
<FilterToolbar max={5} minWidth={200}> <FilterToolbar max={5} minWidth={200}>
<LocationAutocomplete <LocationAutocomplete
locaName={locaName} locaName={locaName}
setAppId={setAppId} setAppId={setAppId}
setLocoName={setLocoName} setLocoName={setLocoName}
setPage={setPage} setPage={() => setPagination((p) => ({ ...p, pageIndex: 0 }))}
placeholder="Select Zone" placeholder="Select Zone"
/> />
@@ -639,19 +999,23 @@ export default function OrdersDetails() {
icon={<MdAssignment size={28} />} icon={<MdAssignment size={28} />}
/> />
) : ( ) : (
rows?.map((row, index) => { // Same paginated + sorted row model the desktop table
// reads, so mobile and desktop never disagree on which
// page is showing for the same filters.
table.getRowModel().rows.map((tableRow, index) => {
const row = tableRow.original;
const rowStatusMeta = getStatusMeta(row.orderstatus); const rowStatusMeta = getStatusMeta(row.orderstatus);
const isDelivered = row.orderstatus === 'delivered'; const isDelivered = row.orderstatus === 'delivered';
return ( return (
<MobileCard <MobileCard
key={row.deliveryid || `${row.orderid}-${index}`} key={tableRow.id}
accent={rowStatusMeta.color} accent={rowStatusMeta.color}
header={ header={
<VStack gap={1.5} padding={0}> <VStack gap={1.5} padding={0}>
<HStack justify="between" vAlign="start" gap={1}> <HStack justify="between" vAlign="start" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}> <HStack gap={1.5} vAlign="center" minWidth={0}>
<Text type="supporting" weight="bold" color="secondary"> <Text type="supporting" weight="bold" color="secondary">
{String(page * rowsPerPage + index + 1).padStart(2, '0')} {String(pagination.pageIndex * pagination.pageSize + index + 1).padStart(2, '0')}
</Text> </Text>
<AccentAvatar color={BRAND} size={32}> <AccentAvatar color={BRAND} size={32}>
<MdGroups size={16} /> <MdGroups size={16} />
@@ -802,248 +1166,168 @@ export default function OrdersDetails() {
)} )}
</MobileCardList> </MobileCardList>
) : ( ) : (
<TableScroll minWidth={1600}> <VStack gap={0} padding={0} style={{ flex: 1, minHeight: 0 }}>
<Table density="balanced" dividers="rows" hasHover> <TableScroll minWidth={table.getTotalSize()} maxHeight="100%">
<TableHeader> {/* table-layout: fixed makes the declared column widths
<TableRow> authoritative — same reasoning as orders.js's/riders.js's/
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> Tenants.js's tables. Header and body are both rendered
<TableHeaderCell>Map</TableHeaderCell> FROM the column model, so they cannot drift apart. */}
<TableHeaderCell>Client</TableHeaderCell> <Table
<TableHeaderCell>Pickup</TableHeaderCell> density="balanced"
<TableHeaderCell>Drop</TableHeaderCell> dividers="rows"
<TableHeaderCell>Status / Rider</TableHeaderCell> hasHover
<TableHeaderCell>Assigned</TableHeaderCell> verticalAlign="middle"
<TableHeaderCell>Accepted</TableHeaderCell> aria-label="Orders Details"
<TableHeaderCell>Arrived</TableHeaderCell> style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
<TableHeaderCell>Picked</TableHeaderCell> >
<TableHeaderCell>Active</TableHeaderCell> <TableHeader>
<TableHeaderCell>Delivered</TableHeaderCell> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell>Cancelled</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Notes</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>KMS</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Charges</TableHeaderCell> const canSort = header.column.getCanSort();
</TableRow> const sorted = header.column.getIsSorted();
</TableHeader> return (
<TableHeaderCell
<TableBody> key={header.id}
{fetchDeliveriesIsLoading ? ( scope="col"
<TableLoader rows={16} columns={16} /> aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
) : rows?.length == 0 ? ( style={{
<TableRow> width: header.getSize(),
<TableCell colSpan={16}> minWidth: header.column.columnDef.minSize,
<EmptyState maxWidth: header.column.columnDef.maxSize || header.getSize(),
title="No orders to show" textAlign: align,
description={searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'} position: 'relative'
icon={<MdAssignment size={28} />}
/>
</TableCell>
</TableRow>
) : (
rows?.map((row, index) => {
const isDelivered = row.orderstatus === 'delivered';
const cancelled = row.orderstatus === 'cancelled';
return (
<TableRow key={row.deliveryid || `${row.orderid}-${index}`}>
<TableCell style={{ verticalAlign: 'top' }}>
<Text type="supporting" weight="bold" color="secondary">
{String(page * rowsPerPage + index + 1).padStart(2, '0')}
</Text>
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<IconButton
label={isDelivered ? 'View rider route' : 'Available for delivered orders'}
tooltip={isDelivered ? 'View rider route' : 'Available for delivered orders'}
size="sm"
variant="ghost"
icon={<MdMap size={14} />}
isDisabled={!isDelivered}
onClick={() => {
if (isDelivered) {
getdeliverylogs(row.deliveryid);
setMapTenant(row);
}
}} }}
/> >
</TableCell> <HStack
gap={1}
<TableCell style={{ verticalAlign: 'top' }}> vAlign="center"
<HStack gap={1.5} vAlign="start"> justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
<AccentAvatar color={BRAND} size={32}> role={canSort ? 'button' : undefined}
<MdGroups size={16} /> tabIndex={canSort ? 0 : undefined}
</AccentAvatar> onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
<VStack gap={0} padding={0} minWidth={0}> onKeyDown={
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}> canSort
{row.tenantname} ? (e) => {
</Text> if (e.key === 'Enter' || e.key === ' ') {
<Tooltip content="Order ID"> e.preventDefault();
<Text type="supporting" color="secondary" weight="bold"> header.column.toggleSorting();
#{row.orderid} }
</Text> }
</Tooltip> : undefined
<Text type="supporting" color="secondary"> }
{parseDoormileTimestamp(row.deliverydate).format('DD/MM/YYYY · hh:mm A')} style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
</Text> >
</VStack> {flexRender(header.column.columnDef.header, header.getContext())}
</HStack> {canSort &&
</TableCell> (sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
<TableCell style={{ verticalAlign: 'top' }}> ) : sorted === 'desc' ? (
<VStack gap={0} padding={0}> <MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}> ) : (
{row.pickupcustomer || '—'} <MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
</Text> ))}
<Text type="supporting" color="secondary">
{row.pickupcontactno}
</Text>
<Tooltip content={row.Pickupaddress || row.pickupsuburb || ''}>
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
{row.pickupsuburb || (row.Pickupaddress ? row.Pickupaddress.slice(0, 22) + '…' : '')}
</Text>
</Tooltip>
{row.applocation && <Badge variant="green" icon={<MdPlace size={11} />} label={row.applocation} />}
</VStack>
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{row.deliverycustomer || '—'}
</Text>
<Text type="supporting" color="secondary">
{row.deliverycontactno}
</Text>
<Tooltip content={row.deliveryaddress || ''}>
<Text type="supporting" color="secondary" maxLines={1} style={{ cursor: 'pointer' }}>
{row.deliverysuburb || (row.deliveryaddress ? row.deliveryaddress.slice(0, 22) + '…' : '')}
</Text>
</Tooltip>
</VStack>
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<VStack gap={1} padding={0}>
<StatusBadge status={row.orderstatus} />
{row.ridername && (
<HStack gap={1} vAlign="center">
<MdDirectionsBike size={12} color={DT.textMuted} />
<Tooltip content={`Rider ID: ${row.userid}`}>
<Text weight="bold" style={{ whiteSpace: 'nowrap' }}>
{row.ridername}
</Text>
</Tooltip>
</HStack>
)}
</VStack>
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<StampCell value={row.assigntime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<StampCell value={row.acceptedtime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<StampCell value={row.arrivaltime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
{/* GET /admin/bookings has no per-event timestamps (accepted/
arrived/pickup/start/cancel) — only assigntime (updatedat) and
deliverydate exist on the mapped row (see fetchDeliveries in
api.js), so row.pickuptime is always undefined and this cell
always renders the muted dash. The old `success` check compared
against a `deliverystatus` field that never existed on this row
either (the real field is `orderstatus`) — dropped rather than
guessed, since orderstatus's confirmed values
(pending/accepted/active/delivered/cancelled) have no distinct
"picked" state to map it to. */}
<StampCell value={row.pickuptime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<StampCell value={row.starttime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<StampCell value={row.deliverytime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top' }}>
<StampCell value={row.canceltime} formatDate={formatDate} formatTime={formatTime} />
</TableCell>
<TableCell style={{ verticalAlign: 'top', maxWidth: 180 }}>
{row.ordernotes ? (
<HStack gap={1} vAlign="start">
<MdNoteAlt size={12} color={DT.textMuted} style={{ marginTop: 2, flexShrink: 0 }} />
<Text type="supporting" color="secondary" maxLines={2}>
{row.ordernotes}
</Text>
</HStack> </HStack>
) : (
<Text type="supporting" color="secondary">
—
</Text>
)}
</TableCell>
<TableCell style={{ verticalAlign: 'top', textAlign: 'center' }}> {header.column.getCanResize() && (
<VStack gap={1} padding={0} vAlign="center"> <Center
<MetricPill aria-hidden="true"
variant="red" onMouseDown={header.getResizeHandler()}
icon={<MdStraighten size={11} />} onTouchStart={header.getResizeHandler()}
label={cancelled || row.kms == '' ? '0.00 km' : `${formatKm(row.kms)} km`} style={{
tooltip="KMS" position: 'absolute',
/> top: 0,
<MetricPill right: 0,
variant="green" height: '100%',
icon={<MdStraighten size={11} />} width: 10,
label={`${formatKm(row.cumulativekms)} km`} cursor: 'col-resize',
tooltip="Actual KMS" userSelect: 'none',
/> touchAction: 'none'
<MetricPill }}
variant="blue" >
icon={<MdStraighten size={11} />} <Center
label={`${formatKm(row.previouskms || (cancelled ? 0 : row.kms))} km`} style={{
tooltip="Rider KMS" width: 2,
/> height: '60%',
</VStack> borderRadius: 'var(--radius-element)',
</TableCell> backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader>
<TableCell style={{ verticalAlign: 'top', textAlign: 'center' }}> <TableBody>
<VStack gap={1} padding={0} vAlign="center"> {fetchDeliveriesIsLoading ? (
<MetricPill <TableLoader rows={16} columns={colCount} />
variant="red" ) : rows?.length == 0 ? (
icon={<MdCurrencyRupee size={11} />} <TableRow>
label={cancelled || row.deliverycharges == '' ? `0.00` : `${row.deliverycharges}.00`} <TableCell colSpan={colCount}>
tooltip="Delivery Charge" <EmptyState
/> title="No orders to show"
<MetricPill description={searchword ? 'Try a different keyword.' : 'Adjust the filters above to load orders.'}
variant="green" icon={<MdAssignment size={28} />}
icon={<MdCurrencyRupee size={11} />} />
label={row.deliveryamt == '' ? `0.00` : `${row.deliveryamt}.00`} </TableCell>
tooltip="Delivery Amount" </TableRow>
/> ) : (
</VStack> table.getRowModel().rows.map((tableRow) => {
</TableCell> return (
</TableRow> <TableRow key={tableRow.id}>
); {tableRow.getAllCells().map((cell) => (
}) <TableCell
)} key={cell.id}
</TableBody> style={{
</Table> verticalAlign: 'top',
</TableScroll> textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
);
})
)}
</TableBody>
</Table>
</TableScroll>
</VStack>
)} )}
{/* Pagination replaces the old scroll-to-reveal sentinel — the
{rows?.length !== 0 && ( unconditional drain effect above already pulls every matching
order into `allRows` regardless of scroll position, so this
was always paginating an in-memory array, just without any UI
for it. */}
{rows?.length > 0 && (
<> <>
<Divider /> <Divider />
<VStack vAlign="center" padding={3} ref={loadMoreRef}> <HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
{isFetchingNextPage || hasNextPage ? ( <Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
<LoaderWithImage /> {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
) : ( (pagination.pageIndex + 1) * pagination.pageSize,
<Text type="supporting" weight="bold" color="secondary"> rows.length
No more orders )} of ${rows.length} order${rows.length === 1 ? '' : 's'}`}
</Text> </Text>
)} <Pagination
</VStack> page={pagination.pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Orders details pagination"
/>
</HStack>
</> </>
)} )}
</> </>

View File

@@ -19,12 +19,34 @@ import {
MdOutlineLocalShipping, MdOutlineLocalShipping,
MdOutlinePendingActions, MdOutlinePendingActions,
MdOutlineCheckCircle, MdOutlineCheckCircle,
MdOutlineCurrencyRupee MdOutlineCurrencyRupee,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md'; } from 'react-icons/md';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
dayjs.extend(utc); dayjs.extend(utc);
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use. This
// table's two-row grouped header (Orders/Deliveries each spanning 3 leaf
// columns) uses TanStack's native column-grouping (`columns: [...]` nested
// under a parent column) — `header.rowSpan`/`header.colSpan` are computed
// automatically from the group depth, so the header render loop below skips
// any header reporting `rowSpan === 0` (its cell was already merged into a
// taller cell above it) and renders every other header with its own
// rowSpan/colSpan, per table-core's documented placeholder-chain contract.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
@@ -36,6 +58,7 @@ import { Badge } from '@astryxdesign/core/Badge';
import { Divider } from '@astryxdesign/core/Divider'; import { Divider } from '@astryxdesign/core/Divider';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { getreportlocationsummary, getreportsummary, gettenantlocations, getTenants } from 'pages/api/api'; import { getreportlocationsummary, getreportsummary, gettenantlocations, getTenants } from 'pages/api/api';
@@ -124,6 +147,31 @@ function formatNumberToRupees(value) {
}).format(Number(value) || 0); }).format(Number(value) || 0);
} }
// TanStack Table v9 registers features explicitly. Sorting + pagination only
// here — deliberately NO columnSizingFeature/columnResizingFeature: this
// table's 2-row grouped header (Orders/Deliveries each colSpan={3}) makes a
// per-column resize handle ambiguous (which row does the handle belong to?
// what does resizing a group header even mean?), a materially fiddlier UX
// problem than the other converted pages. Sorting/pagination carry the same
// value without that complication.
const ORDERS_SUMMARY_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// ============================================================================ // ============================================================================
// Orders Summary // Orders Summary
// ============================================================================ // ============================================================================
@@ -144,6 +192,9 @@ export default function OrdersReport() {
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [, setPage] = useState(0); const [, setPage] = useState(0);
// Table sorting/pagination state — mirrors orders.js's/riders.js's slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
// Clear nested filters when scope widens. // Clear nested filters when scope widens.
useEffect(() => { useEffect(() => {
@@ -360,6 +411,242 @@ export default function OrdersReport() {
</Card> </Card>
); );
// A zone/tenant/location/date/search change re-scopes the list, so page 1
// is the only page that still means anything — same reset orders.js/
// riders.js do on their own tables.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [appId, tenantid, locationid, startdate, enddate, debouncedSearch]);
const ORDERS_GROUP_BG = tint('#ef4444');
const DELIVERIES_GROUP_BG = tint('#10b981');
// The column model. Orders/Deliveries are TanStack column GROUPS (a
// `columns:` array nested under a parent column def) — this is what
// produces the two-row header: `table.getHeaderGroups()` returns one row
// per depth level, and TanStack computes each header's rowSpan/colSpan
// from the group structure automatically (see the import comment above for
// the placeholder-chain contract the render loop follows).
const columns = useMemo(
() => [
{
id: 'serial',
size: 44,
minSize: 36,
enableSorting: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'group',
accessorFn: (row) => (isLocationGroup ? row.locationname : row.tenantname) || '—',
size: 220,
minSize: 180,
meta: { label: isLocationGroup ? 'Location' : 'Tenant' },
header: () => <SectionLabel>{isLocationGroup ? 'Location' : 'Tenant'}</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={2} vAlign="center">
<AccentAvatar color={isLocationGroup ? '#0ea5e9' : BRAND} size={36}>
{isLocationGroup ? <MdLocationOn size={18} /> : <MdStore size={18} />}
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary">
ID #{isLocationGroup ? row.original.locationid : row.original.tenantid}
</Text>
</VStack>
</HStack>
)
},
{
id: 'all',
accessorKey: 'totalorders',
size: 100,
minSize: 80,
meta: { label: 'All', align: 'center' },
header: () => <SectionLabel>All</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color={BRAND} icon={<MdLocalShipping size={11} />} />
},
{
id: 'ordersGroup',
header: () => <SectionLabel>Orders</SectionLabel>,
meta: { headerBg: ORDERS_GROUP_BG, headerColor: '#ef4444' },
columns: [
{
id: 'orderspending',
accessorKey: 'Orderspending',
size: 110,
minSize: 90,
meta: { label: 'Orders · Pending', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#f59e0b' },
header: () => <SectionLabel>Pending</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
},
{
id: 'orderscancelled',
accessorKey: 'orderscancelled',
size: 110,
minSize: 90,
meta: { label: 'Orders · Cancelled', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#ef4444' },
header: () => <SectionLabel>Cancelled</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#ef4444" icon={<MdCancel size={11} />} />
},
{
id: 'orderscompleted',
accessorKey: 'orderscompleted',
size: 110,
minSize: 90,
meta: { label: 'Orders · Completed', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#10b981' },
header: () => <SectionLabel>Completed</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#10b981" icon={<MdCheckCircle size={11} />} />
}
]
},
{
id: 'deliveriesGroup',
header: () => <SectionLabel>Deliveries</SectionLabel>,
meta: { headerBg: DELIVERIES_GROUP_BG, headerColor: '#10b981' },
columns: [
{
id: 'deliveriespending',
accessorKey: 'deliveriespending',
size: 110,
minSize: 90,
meta: { label: 'Deliveries · Pending', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#f59e0b' },
header: () => <SectionLabel>Pending</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
},
{
id: 'deliveriescancelled',
accessorKey: 'deliveriescancelled',
size: 110,
minSize: 90,
meta: { label: 'Deliveries · Cancelled', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#ef4444' },
header: () => <SectionLabel>Cancelled</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#ef4444" icon={<MdCancel size={11} />} />
},
{
id: 'deliveriescompleted',
accessorKey: 'deliveriescompleted',
size: 110,
minSize: 90,
meta: { label: 'Deliveries · Completed', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#10b981' },
header: () => <SectionLabel>Completed</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#10b981" icon={<MdCheckCircle size={11} />} />
}
]
},
{
id: 'collection',
accessorKey: 'collectionamt',
size: 130,
minSize: 110,
meta: { label: 'Collection', align: 'center' },
header: () => <SectionLabel>Collection</SectionLabel>,
cell: ({ getValue }) => (
<MetricPill
color="#f59e0b"
icon={<MdCurrencyRupee size={11} />}
label={Number(getValue() || 0).toFixed(2)}
tooltip="Collection Amount"
minWidth={90}
/>
)
},
{
id: 'kmsActual',
accessorKey: 'kms',
size: 140,
minSize: 120,
meta: { label: 'KMS / Actual', align: 'center' },
header: () => <SectionLabel>KMS / Actual</SectionLabel>,
cell: ({ row }) => (
<VStack gap={1} padding={0} vAlign="center">
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.original.kms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
<MetricPill
color="#10b981"
icon={<MdStraighten size={11} />}
label={`${Number(row.original.cumulativekms || 0).toFixed(2)} km`}
tooltip="Actual KMS"
/>
</VStack>
)
},
{
id: 'amount',
accessorFn: (row) => Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0),
size: 140,
minSize: 120,
meta: { label: 'Amount', align: 'center' },
header: () => <SectionLabel>Amount</SectionLabel>,
cell: ({ getValue }) => (
<MetricPill
color={BRAND}
icon={<MdCurrencyRupee size={11} />}
label={formatNumberToRupees(getValue()).replace('₹', '').trim()}
tooltip="Total Amount"
minWidth={100}
/>
)
},
{
id: 'action',
size: 90,
minSize: 70,
enableSorting: false,
meta: { label: 'Action' },
header: () => <SectionLabel>Action</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
const rowKey = isLocationGroup ? r.locationname : r.tenantname;
const isOpen = openRow === rowKey;
return (
<HStack justify="end">
<IconButton
label={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
tooltip={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
size="sm"
variant={isOpen ? 'primary' : 'ghost'}
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
onClick={() => toggleBreakdown(r, rowKey)}
/>
</HStack>
);
}
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[isLocationGroup, openRow, pagination.pageIndex, pagination.pageSize]
);
const table = useTable({
features: ORDERS_SUMMARY_TABLE_FEATURES,
// filteredRows is already useMemo'd above (stable reference) — required
// for pagination clicks to stick, see riders.js's fix for the same bug.
data: filteredRows,
columns,
getRowId: (row, index) => String((isLocationGroup ? row.locationname : row.tenantname) ?? `row-${index}`),
state: { sorting, pagination },
onSortingChange: setSorting,
onPaginationChange: setPagination
});
// Leaf-column count (not top-level `columns.length`, which mixes 7 leaves
// with 2 group parents) — this is what loading/empty-state colSpans need
// to actually span every rendered data column.
const colCount = table.getAllLeafColumns().length;
return ( return (
<> <>
{(loading || isLoadingReports) && <Loader />} {(loading || isLoadingReports) && <Loader />}
@@ -400,9 +687,9 @@ export default function OrdersReport() {
not a lifecycle list), so the toolbar just holds every filter not a lifecycle list), so the toolbar just holds every filter
including search — same shape as hubs.js/vehicles.js when there's including search — same shape as hubs.js/vehicles.js when there's
nothing to pair search with. The two-row grouped Orders/Deliveries nothing to pair search with. The two-row grouped Orders/Deliveries
header below (rowSpan/colSpan) stays a hand-rolled table rather header is a TanStack Table v9 column-group (see the `columns`
than a TanStack Table v9 column-group conversion — a materially useMemo above) — table-core computes rowSpan/colSpan from the
larger, riskier change than this pass's chrome scope. */} group structure, the render loop below just follows it. */}
<DataCard <DataCard
toolbar={ toolbar={
<FilterToolbar max={4} minWidth={200}> <FilterToolbar max={4} minWidth={200}>
@@ -486,7 +773,11 @@ export default function OrdersReport() {
{!isLoadingReports && filteredRows.length > 0 && ( {!isLoadingReports && filteredRows.length > 0 && (
<MobileCardList scroll> <MobileCardList scroll>
{filteredRows.map((row, index) => { {/* Same paginated + sorted row model the desktop table
reads, so mobile and desktop never disagree on which
page is showing for the same filters. */}
{table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const rowKey = isLocationGroup ? row.locationname : row.tenantname; const rowKey = isLocationGroup ? row.locationname : row.tenantname;
const isOpen = openRow === rowKey; const isOpen = openRow === rowKey;
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0); const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
@@ -494,7 +785,7 @@ export default function OrdersReport() {
return ( return (
<MobileCard <MobileCard
key={`${rowKey}-${index}`} key={tableRow.id}
accent={rowAccent} accent={rowAccent}
selected={isOpen} selected={isOpen}
header={ header={
@@ -592,59 +883,78 @@ export default function OrdersReport() {
</> </>
) : ( ) : (
<TableScroll minWidth={1200}> <TableScroll minWidth={1200}>
<Table density="balanced" dividers="rows" hasHover> <Table density="balanced" dividers="rows" hasHover style={{ width: '100%' }} aria-label="Orders Summary">
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell rowSpan={2}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell rowSpan={2}>{isLocationGroup ? 'Location' : 'Tenant'}</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}> // Placeholder chain: a leaf column shallower than the
All // table's max depth gets padded with placeholders;
</TableHeaderCell> // every header covering the same visual cell after
<TableHeaderCell colSpan={3} style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}> // the first reports rowSpan 0 and must be skipped —
Orders // rendering it would draw an extra empty <th>.
</TableHeaderCell> if (header.rowSpan === 0) return null;
<TableHeaderCell colSpan={3} style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}> const meta = header.column.columnDef.meta;
Deliveries const align = meta?.align;
</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}> const sorted = header.column.getIsSorted();
Collection return (
</TableHeaderCell> <TableHeaderCell
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}> key={header.id}
KMS / Actual scope="col"
</TableHeaderCell> rowSpan={header.rowSpan}
<TableHeaderCell rowSpan={2} style={{ textAlign: 'center' }}> colSpan={header.colSpan}
Amount aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
</TableHeaderCell> style={{
<TableHeaderCell rowSpan={2}>Action</TableHeaderCell> textAlign: align,
</TableRow> backgroundColor: meta?.headerBg,
<TableRow> color: meta?.headerColor
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#f59e0b' }}> }}
Pending >
</TableHeaderCell> {header.isPlaceholder ? null : (
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#ef4444' }}> <HStack
Cancelled gap={1}
</TableHeaderCell> vAlign="center"
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#ef4444'), color: '#10b981' }}> justify={align === 'center' ? 'center' : 'start'}
Completed role={canSort ? 'button' : undefined}
</TableHeaderCell> tabIndex={canSort ? 0 : undefined}
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#f59e0b' }}> onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
Pending onKeyDown={
</TableHeaderCell> canSort
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#ef4444' }}> ? (e) => {
Cancelled if (e.key === 'Enter' || e.key === ' ') {
</TableHeaderCell> e.preventDefault();
<TableHeaderCell style={{ textAlign: 'center', backgroundColor: tint('#10b981'), color: '#10b981' }}> header.column.toggleSorting();
Completed }
</TableHeaderCell> }
</TableRow> : undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} style={{ flexShrink: 0, opacity: 0.5 }} />
))}
</HStack>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoadingReports && <TableLoader rows={5} columns={13} />} {isLoadingReports && <TableLoader rows={5} columns={colCount} />}
{!isLoadingReports && filteredRows.length === 0 && ( {!isLoadingReports && filteredRows.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={13}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title={`No ${isLocationGroup ? 'locations' : 'tenants'} to show`} title={`No ${isLocationGroup ? 'locations' : 'tenants'} to show`}
description={ description={
@@ -660,111 +970,24 @@ export default function OrdersReport() {
</TableRow> </TableRow>
)} )}
{filteredRows.map((row, index) => { {table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const rowKey = isLocationGroup ? row.locationname : row.tenantname; const rowKey = isLocationGroup ? row.locationname : row.tenantname;
const isOpen = openRow === rowKey; const isOpen = openRow === rowKey;
const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0);
return ( return (
<React.Fragment key={`${rowKey}-${index}`}> <React.Fragment key={tableRow.id}>
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}> <TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
<TableCell> {tableRow.getAllCells().map((cell) => (
<Text type="supporting" weight="bold" color="secondary"> <TableCell key={cell.id} style={{ textAlign: cell.column.columnDef.meta?.align }}>
{String(index + 1).padStart(2, '0')} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</Text> </TableCell>
</TableCell> ))}
<TableCell>
<HStack gap={2} vAlign="center">
<AccentAvatar color={isLocationGroup ? '#0ea5e9' : BRAND} size={36}>
{isLocationGroup ? <MdLocationOn size={18} /> : <MdStore size={18} />}
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{(isLocationGroup ? row.locationname : row.tenantname) || '—'}
</Text>
<Text type="supporting" color="secondary">
ID #{isLocationGroup ? row.locationid : row.tenantid}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.totalorders} color={BRAND} icon={<MdLocalShipping size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.Orderspending} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.orderscancelled} color="#ef4444" icon={<MdCancel size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.orderscompleted} color="#10b981" icon={<MdCheckCircle size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.deliveriespending} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.deliveriescancelled} color="#ef4444" icon={<MdCancel size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<CountCell value={row.deliveriescompleted} color="#10b981" icon={<MdCheckCircle size={11} />} />
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<MetricPill
color="#f59e0b"
icon={<MdCurrencyRupee size={11} />}
label={Number(row.collectionamt || 0).toFixed(2)}
tooltip="Collection Amount"
minWidth={90}
/>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<VStack gap={1} padding={0} vAlign="center">
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.kms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
<MetricPill
color="#10b981"
icon={<MdStraighten size={11} />}
label={`${Number(row.cumulativekms || 0).toFixed(2)} km`}
tooltip="Actual KMS"
/>
</VStack>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<MetricPill
color={BRAND}
icon={<MdCurrencyRupee size={11} />}
label={formatNumberToRupees(amount).replace('₹', '').trim()}
tooltip="Total Amount"
minWidth={100}
/>
</TableCell>
<TableCell>
<HStack justify="end">
<IconButton
label={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
tooltip={isOpen ? 'Hide rider breakdown' : 'View rider breakdown'}
size="sm"
variant={isOpen ? 'primary' : 'ghost'}
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
onClick={() => toggleBreakdown(row, rowKey)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
{isOpen && ( {isOpen && (
<TableRow> <TableRow>
<TableCell colSpan={13} style={{ padding: 0, backgroundColor: tint(BRAND) }}> <TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
<RiderBreakdown label={isLocationGroup ? row.locationname : row.tenantname} /> <RiderBreakdown label={isLocationGroup ? row.locationname : row.tenantname} />
</div> </div>
@@ -779,8 +1002,31 @@ export default function OrdersReport() {
</TableScroll> </TableScroll>
)} )}
{/* Pagination sits above the Grand Total bar — the total is
deliberately computed from the FULL filtered set (`stats`,
above), not just the current page, so it keeps summing every
matching row regardless of which page is showing. */}
{filteredRows.length > 0 && ( {filteredRows.length > 0 && (
<> <>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} ${isLocationGroup ? 'location' : 'tenant'}${filteredRows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
totalItems={filteredRows.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Orders summary pagination"
/>
</HStack>
<Divider /> <Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" padding={2} style={{ background: '#ffffff' }}> <HStack justify="between" vAlign="center" gap={2} wrap="wrap" padding={2} style={{ background: '#ffffff' }}>
<HStack gap={2} vAlign="center" wrap="wrap"> <HStack gap={2} vAlign="center" wrap="wrap">

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 { useQuery } from '@tanstack/react-query';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
@@ -17,15 +17,34 @@ import {
MdPerson, MdPerson,
MdOutlineLocalShipping, MdOutlineLocalShipping,
MdOutlineCheckCircle, MdOutlineCheckCircle,
MdOutlineCurrencyRupee MdOutlineCurrencyRupee,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md'; } from 'react-icons/md';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
dayjs.extend(utc); dayjs.extend(utc);
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
@@ -33,6 +52,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip';
import { Divider } from '@astryxdesign/core/Divider'; import { Divider } from '@astryxdesign/core/Divider';
import { Dialog } from '@astryxdesign/core/Dialog'; import { Dialog } from '@astryxdesign/core/Dialog';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { fetchRidersSummary } from 'pages/api/api'; import { fetchRidersSummary } from 'pages/api/api';
@@ -121,6 +141,29 @@ function formatNumberToRupees(value) {
}).format(Number(value) || 0); }).format(Number(value) || 0);
} }
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const RIDERS_SUMMARY_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// ==============================|| Riders Summary ||============================== // // ==============================|| Riders Summary ||============================== //
export default function RidersSummary() { export default function RidersSummary() {
@@ -141,6 +184,11 @@ export default function RidersSummary() {
const [routeLoading, setRouteLoading] = useState(false); const [routeLoading, setRouteLoading] = useState(false);
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
// ==============================|| fetchRidersSummary ||============================== // // ==============================|| fetchRidersSummary ||============================== //
const { isLoading: isLoadingReports, data: rows } = useQuery({ const { isLoading: isLoadingReports, data: rows } = useQuery({
@@ -162,6 +210,12 @@ export default function RidersSummary() {
return rows.filter((r) => [r.displayname, String(r.userid)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q))); return rows.filter((r) => [r.displayname, String(r.userid)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q)));
}, [rows, debouncedSearch]); }, [rows, debouncedSearch]);
// A zone/date/search change re-scopes the list, so page 1 is the only page
// that still means anything — same reset orders.js/riders.js do.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [appId, startdate, enddate, debouncedSearch]);
// Aggregate KPIs from the loaded summary. // Aggregate KPIs from the loaded summary.
const stats = useMemo(() => { const stats = useMemo(() => {
if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 }; if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 };
@@ -287,6 +341,169 @@ export default function RidersSummary() {
</Card> </Card>
); );
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as
// orders.js's/riders.js's/Tenants.js's `columns`. Row-expand state
// (openRow) was already keyed by `row.userid`, not position, so it stays
// correct once sorting/pagination reorder rows — no change needed there.
const columns = useMemo(
() => [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'rider',
accessorFn: (row) => row.displayname || '—',
size: 220,
minSize: 180,
meta: { label: 'Rider' },
header: () => <SectionLabel>Rider</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} size={36}>
<MdPerson size={18} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary">
ID #{row.original.userid}
</Text>
</VStack>
</HStack>
)
},
{
id: 'orders',
accessorKey: 'assigned',
size: 110,
minSize: 90,
meta: { label: 'Orders' },
header: () => <SectionLabel>Orders</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color={BRAND} icon={<MdLocalShipping size={11} />} />
},
{
id: 'accepted',
accessorKey: 'accepted',
size: 110,
minSize: 90,
meta: { label: 'Accepted' },
header: () => <SectionLabel>Accepted</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
},
{
id: 'cancelled',
accessorKey: 'cancelled',
size: 110,
minSize: 90,
meta: { label: 'Cancelled' },
header: () => <SectionLabel>Cancelled</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#ef4444" icon={<MdCancel size={11} />} />
},
{
id: 'delivered',
accessorKey: 'delivered',
size: 110,
minSize: 90,
meta: { label: 'Delivered' },
header: () => <SectionLabel>Delivered</SectionLabel>,
cell: ({ getValue }) => <CountCell value={getValue()} color="#10b981" icon={<MdCheckCircle size={11} />} />
},
{
id: 'kms',
accessorKey: 'riderkms',
size: 120,
minSize: 100,
meta: { label: 'Kms' },
header: () => <SectionLabel>Kms</SectionLabel>,
cell: ({ getValue }) => (
<MetricPill color="#ef4444" icon={<MdStraighten size={11} />} label={`${Number(getValue() || 0).toFixed(2)} km`} tooltip="KMS" />
)
},
{
id: 'amount',
accessorFn: (row) => Number(row.ridercharges) || 0,
size: 140,
minSize: 120,
meta: { label: 'Amount' },
header: () => <SectionLabel>Amount</SectionLabel>,
cell: ({ getValue }) => (
<MetricPill
color={BRAND}
icon={<MdCurrencyRupee size={11} />}
label={formatNumberToRupees(getValue()).replace('₹', '').trim()}
tooltip="Total Amount"
minWidth={100}
/>
)
},
{
id: 'action',
size: 110,
minSize: 90,
enableSorting: false,
enableResizing: false,
meta: { label: 'Action' },
header: () => <SectionLabel>Action</SectionLabel>,
cell: ({ row }) => {
const r = row.original;
const isOpen = openRow === r.userid;
return (
<HStack gap={0.75} justify="end">
<IconButton
label="View planned route"
tooltip="View planned route"
size="sm"
variant="ghost"
icon={<MdMap size={14} />}
onClick={() => openRoutePreview(r)}
/>
<IconButton
label={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
tooltip={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
size="sm"
variant={isOpen ? 'primary' : 'ghost'}
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
onClick={() => toggleBreakdown(r)}
/>
</HStack>
);
}
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[pagination.pageIndex, pagination.pageSize, openRow]
);
const table = useTable({
features: RIDERS_SUMMARY_TABLE_FEATURES,
// filteredRows is already useMemo'd above (stable reference) — required
// for pagination clicks to stick, see riders.js's fix for the same bug.
data: filteredRows,
columns,
getRowId: (row, index) => String(row.userid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
const colCount = columns.length;
return ( return (
<> <>
{(isLoadingReports || loading) && <Loader />} {(isLoadingReports || loading) && <Loader />}
@@ -350,13 +567,17 @@ export default function RidersSummary() {
icon={<MdDirectionsBike size={28} />} icon={<MdDirectionsBike size={28} />}
/> />
) : ( ) : (
filteredRows.map((row, index) => { // Same paginated + sorted row model the desktop table reads,
// so mobile and desktop never disagree on what page is
// showing for the same filters.
table.getRowModel().rows.map((tableRow, index) => {
const row = tableRow.original;
const isOpen = openRow === row.userid; const isOpen = openRow === row.userid;
const amount = Number(row.ridercharges) || 0; const amount = Number(row.ridercharges) || 0;
const riderName = row?.displayname || '—'; const riderName = row?.displayname || '—';
return ( return (
<MobileCard <MobileCard
key={row.userid || index} key={tableRow.id}
accent={BRAND} accent={BRAND}
selected={isOpen} selected={isOpen}
header={ header={
@@ -370,7 +591,7 @@ export default function RidersSummary() {
{riderName} {riderName}
</Text> </Text>
<Text type="supporting" color="secondary"> <Text type="supporting" color="secondary">
#{String(index + 1).padStart(2, '0')} · ID #{row.userid} #{String(pagination.pageIndex * pagination.pageSize + index + 1).padStart(2, '0')} · ID #{row.userid}
</Text> </Text>
</VStack> </VStack>
</HStack> </HStack>
@@ -434,27 +655,108 @@ export default function RidersSummary() {
)} )}
</MobileCardList> </MobileCardList>
) : ( ) : (
<TableScroll minWidth={1080}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Riders Summary"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Rider</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Orders</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Accepted</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Cancelled</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Delivered</TableHeaderCell> return (
<TableHeaderCell>KMS</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>Amount</TableHeaderCell> key={header.id}
<TableHeaderCell>Action</TableHeaderCell> scope="col"
</TableRow> aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoadingReports && <TableLoader rows={5} columns={9} />} {isLoadingReports && <TableLoader rows={5} columns={colCount} />}
{(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? ( {(!filteredRows || filteredRows.length === 0) && !isLoadingReports ? (
<TableRow> <TableRow>
<TableCell colSpan={9}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No riders to show" title="No riders to show"
description={searchword ? 'Try a different keyword.' : 'Pick a zone or date range to load the summary.'} description={searchword ? 'Try a different keyword.' : 'Pick a zone or date range to load the summary.'}
@@ -463,91 +765,28 @@ export default function RidersSummary() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredRows.map((row, index) => { table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const isOpen = openRow === row.userid; const isOpen = openRow === row.userid;
const amount = Number(row.ridercharges) || 0;
return ( return (
<React.Fragment key={row.userid || index}> <React.Fragment key={tableRow.id}>
<TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}> <TableRow style={{ backgroundColor: isOpen ? tint(BRAND) : undefined }}>
<TableCell> {tableRow.getAllCells().map((cell) => (
<Text type="supporting" weight="bold" color="secondary"> <TableCell
{String(index + 1).padStart(2, '0')} key={cell.id}
</Text> style={{
</TableCell> textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell> }}
<HStack gap={2} vAlign="center"> >
<AccentAvatar color={BRAND} size={36}> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<MdPerson size={18} /> </TableCell>
</AccentAvatar> ))}
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{row?.displayname || '—'}
</Text>
<Text type="supporting" color="secondary">
ID #{row.userid}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell>
<CountCell value={row.assigned} color={BRAND} icon={<MdLocalShipping size={11} />} />
</TableCell>
<TableCell>
<CountCell value={row.accepted} color="#f59e0b" icon={<MdHourglassEmpty size={11} />} />
</TableCell>
<TableCell>
<CountCell value={row.cancelled} color="#ef4444" icon={<MdCancel size={11} />} />
</TableCell>
<TableCell>
<CountCell value={row.delivered} color="#10b981" icon={<MdCheckCircle size={11} />} />
</TableCell>
<TableCell>
<MetricPill
color="#ef4444"
icon={<MdStraighten size={11} />}
label={`${Number(row.riderkms || 0).toFixed(2)} km`}
tooltip="KMS"
/>
</TableCell>
<TableCell>
<MetricPill
color={BRAND}
icon={<MdCurrencyRupee size={11} />}
label={formatNumberToRupees(amount).replace('₹', '').trim()}
tooltip="Total Amount"
minWidth={100}
/>
</TableCell>
<TableCell>
<HStack gap={0.75} justify="end">
<IconButton
label="View planned route"
tooltip="View planned route"
size="sm"
variant="ghost"
icon={<MdMap size={14} />}
onClick={() => openRoutePreview(row)}
/>
<IconButton
label={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
tooltip={isOpen ? 'Collapse breakdown' : 'Expand breakdown'}
size="sm"
variant={isOpen ? 'primary' : 'ghost'}
icon={isOpen ? <MdExpandLess size={16} /> : <MdExpandMore size={16} />}
onClick={() => toggleBreakdown(row)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
{isOpen && ( {isOpen && (
<TableRow> <TableRow>
<TableCell colSpan={9} style={{ padding: 0, backgroundColor: tint(BRAND) }}> <TableCell colSpan={colCount} style={{ padding: 0, backgroundColor: tint(BRAND) }}>
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
<TenantBreakdown label={row?.displayname || ''} /> <TenantBreakdown label={row?.displayname || ''} />
</div> </div>
@@ -563,8 +802,31 @@ export default function RidersSummary() {
</TableScroll> </TableScroll>
)} )}
{/* Pagination sits above the Grand Total bar — the total is
deliberately computed from the FULL filtered set (`total`,
above), not just the current page, so it keeps summing every
matching rider regardless of which page is showing. */}
{filteredRows.length > 0 && ( {filteredRows.length > 0 && (
<> <>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(p) => table.setPageIndex(p - 1)}
totalItems={filteredRows.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Riders summary pagination"
/>
</HStack>
<Divider /> <Divider />
<HStack justify="end" vAlign="center" gap={2} padding={2} style={{ background: '#ffffff' }}> <HStack justify="end" vAlign="center" gap={2} padding={2} style={{ background: '#ffffff' }}>
<Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}> <Text type="label" weight="bold" color="secondary" style={{ letterSpacing: 0.6, textTransform: 'uppercase' }}>

View File

@@ -1,17 +1,44 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { MdCheckCircle, MdCancel, MdAccessTime, MdInventory2, MdTwoWheeler, MdArrowForward } from 'react-icons/md'; import {
MdCheckCircle,
MdCancel,
MdAccessTime,
MdInventory2,
MdTwoWheeler,
MdArrowForward,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { Badge } from '@astryxdesign/core/Badge'; import { Badge } from '@astryxdesign/core/Badge';
import { Divider } from '@astryxdesign/core/Divider';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { DateInput } from '@astryxdesign/core/DateInput'; import { DateInput } from '@astryxdesign/core/DateInput';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
@@ -22,6 +49,29 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand; const BRAND = DT.brand;
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, matching
// orders.js's/riders.js's/Tenants.js's tables).
const SUBSTITUTION_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const SUBSTITUTION_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const STATUS_META = { const STATUS_META = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' }, active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' }, inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' },
@@ -55,6 +105,11 @@ export default function RiderSubstitution({
const isMobile = useIsNarrowViewport(); const isMobile = useIsNarrowViewport();
const [selectedBatch, setSelectedBatch] = useState('all'); const [selectedBatch, setSelectedBatch] = useState('all');
const [isFinalizing, setIsFinalizing] = useState(false); const [isFinalizing, setIsFinalizing] = useState(false);
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const getBatchForTime = React.useCallback((timeStr) => { const getBatchForTime = React.useCallback((timeStr) => {
if (!timeStr) return 'unknown'; if (!timeStr) return 'unknown';
@@ -136,6 +191,12 @@ export default function RiderSubstitution({
return (rows || []).filter((row) => getShiftBatch(row.starttime) === selectedBatch); return (rows || []).filter((row) => getShiftBatch(row.starttime) === selectedBatch);
}, [rows, selectedBatch]); }, [rows, selectedBatch]);
// A batch-filter change re-scopes the list, so page 1 is the only page
// that still means anything.
React.useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [selectedBatch]);
const hasAssignments = Object.values(substituteAssignments || {}).some((val) => val !== null && val !== undefined); const hasAssignments = Object.values(substituteAssignments || {}).some((val) => val !== null && val !== undefined);
// The /substitutions resource has no equivalent in the new Doormile Express // The /substitutions resource has no equivalent in the new Doormile Express
@@ -176,6 +237,133 @@ export default function RiderSubstitution({
/> />
); );
// The column model — declared fresh every render (not useMemo'd): the
// 'substitute' and 'status' cells close over allRidersList/
// isBatchDisabled/substituteAssignments/getRowStatusMeta, which change
// often, and memoizing here would need an unmanageable dependency list
// (same tradeoff Tenants.js's actions column made). Recomputing ~6 column
// defs a render is cheap.
const columns = [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'id',
accessorKey: 'userid',
size: 90,
minSize: 70,
meta: { label: 'ID' },
header: () => <SectionLabel>ID</SectionLabel>,
cell: ({ getValue }) => (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
}}
>
#{getValue()}
</div>
)
},
{
id: 'rider',
accessorFn: (row) => row.username || row.fullname || '—',
size: 220,
minSize: 180,
meta: { label: 'Rider' },
header: () => <SectionLabel>Rider</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.original.fullname || row.original.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold">{getValue()}</Text>
<Text type="supporting" color="secondary">
{row.original.contactno || '—'}
</Text>
</VStack>
</HStack>
)
},
{
id: 'arrow',
size: 56,
minSize: 48,
maxSize: 64,
enableSorting: false,
enableResizing: false,
meta: { label: '', align: 'center' },
header: () => null,
cell: () => <MdArrowForward size={18} color={DT.textSecondary} />
},
{
id: 'substitute',
size: 240,
minSize: 200,
enableSorting: false,
enableResizing: false,
meta: { label: 'Substitute Rider' },
header: () => <SectionLabel>Substitute Rider</SectionLabel>,
cell: ({ row }) => {
const riderProfile = allRidersList?.find((r) => r.userid === row.original.userid);
const starttime = riderProfile?.starttime || row.original.starttime;
return renderSubstitutePicker(row.original, isBatchDisabled(getBatchForTime(starttime)));
}
},
{
id: 'status',
accessorFn: (row) => ((row?.status || '').toLowerCase() === 'active' ? 'active' : 'inactive'),
size: 130,
minSize: 110,
meta: { label: 'Status', align: 'center' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ row }) => {
const meta = getRowStatusMeta(row.original);
const Icon = meta.icon;
return <Badge variant={meta.badge} icon={<Icon size={12} />} label={meta.label} />;
}
}
];
const table = useTable({
features: SUBSTITUTION_TABLE_FEATURES,
data: filteredRows,
columns,
getRowId: (row, index) => String(row.userid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Read off columns.length directly (not table.getVisibleLeafColumns()):
// that method requires columnVisibilityFeature, which this table doesn't
// register (no column-hide menu here).
const colCount = columns.length;
const filterRow = ( const filterRow = (
<HStack justify="between" vAlign="center" gap={3} wrap="wrap"> <HStack justify="between" vAlign="center" gap={3} wrap="wrap">
<HStack gap={2} vAlign="center" wrap="wrap"> <HStack gap={2} vAlign="center" wrap="wrap">
@@ -205,13 +393,14 @@ export default function RiderSubstitution({
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} /> <EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
) : ( ) : (
<MobileCardList scroll> <MobileCardList scroll>
{filteredRows?.map((row, index) => { {table.getRowModel().rows.map((tableRow, index) => {
const row = tableRow.original;
const statusMeta = getRowStatusMeta(row); const statusMeta = getRowStatusMeta(row);
const riderProfile = allRidersList?.find((r) => r.userid === row.userid); const riderProfile = allRidersList?.find((r) => r.userid === row.userid);
const starttime = riderProfile?.starttime || row.starttime; const starttime = riderProfile?.starttime || row.starttime;
return ( return (
<MobileCard <MobileCard
key={row.userid ?? index} key={tableRow.id ?? index}
accent={statusMeta.color} accent={statusMeta.color}
header={ header={
<VStack gap={2} padding={0}> <VStack gap={2} padding={0}>
@@ -265,89 +454,160 @@ export default function RiderSubstitution({
</MobileCardList> </MobileCardList>
) )
) : ( ) : (
<TableScroll minWidth={1200}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Rider substitutes"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>ID</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Rider</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell style={{ textAlign: 'center' }}></TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Substitute Rider</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell> return (
</TableRow> <TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredRows?.length === 0 && ( {filteredRows?.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6}> <TableCell colSpan={colCount}>
<EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} /> <EmptyState title="No active riders to show" icon={<MdTwoWheeler size={28} />} />
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{filteredRows?.length !== 0 && {filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => { table.getRowModel().rows.map((tableRow) => (
const statusMeta = getRowStatusMeta(row); <TableRow key={tableRow.id}>
const riderProfile = allRidersList?.find((r) => r.userid === row.userid); {tableRow.getAllCells().map((cell) => (
const starttime = riderProfile?.starttime || row.starttime; <TableCell
return ( key={cell.id}
<TableRow key={row.userid ?? index}> style={{
<TableCell> textAlign: cell.column.columnDef.meta?.align,
<Text type="supporting" weight="bold" color="secondary"> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
{String(index + 1).padStart(2, '0')} }}
</Text> >
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell> </TableCell>
<TableCell> ))}
<div </TableRow>
style={{ ))}
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
}}
>
#{row?.userid}
</div>
</TableCell>
<TableCell>
<HStack gap={2} vAlign="center">
<AccentAvatar color={BRAND} selected size={36}>
<Text weight="bold">{(row.fullname || row.username || '?').charAt(0).toUpperCase()}</Text>
</AccentAvatar>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold">{row.username || '—'}</Text>
<Text type="supporting" color="secondary">
{row.contactno || '—'}
</Text>
</VStack>
</HStack>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<MdArrowForward size={18} color={DT.textSecondary} />
</TableCell>
<TableCell style={{ minWidth: 220 }}>
{renderSubstitutePicker(row, isBatchDisabled(getBatchForTime(starttime)))}
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
</TableCell>
</TableRow>
);
})}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
)} )}
{/* Page-number pagination, matching orders.js's/riders.js's/
Tenants.js's footer shape. Currently near-moot (rows is always []
— see the module comment on filteredRows), but wired correctly for
when the /substitutions resource gets a real backend. */}
{filteredRows?.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={3} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={filteredRows.length}
pageSize={pagination.pageSize}
pageSizeOptions={SUBSTITUTION_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Rider substitutes pagination"
/>
</HStack>
</>
)}
{hasAssignments && ( {hasAssignments && (
<HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}> <HStack justify="end" padding={3} style={{ borderTop: `1px solid ${DT.borderSubtle}`, backgroundColor: DT.surfaceAlt }}>
<Button <Button

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'; import { useNavigate } from 'react-router-dom';
var utc = require('dayjs/plugin/utc'); var utc = require('dayjs/plugin/utc');
import dayjs from 'dayjs'; import dayjs from 'dayjs';
dayjs.extend(utc); dayjs.extend(utc);
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
// TanStack Table v9 — same engine orders.js uses, so sorting/resizing/
// pagination behave identically across the two pages.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { import {
MdCheckCircle, MdCheckCircle,
MdCancel, MdCancel,
@@ -14,6 +28,9 @@ import {
MdEdit, MdEdit,
MdKeyboardArrowDown, MdKeyboardArrowDown,
MdKeyboardArrowUp, MdKeyboardArrowUp,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore,
MdLocationOn, MdLocationOn,
MdBatteryStd, MdBatteryStd,
MdPowerSettingsNew, MdPowerSettingsNew,
@@ -32,6 +49,7 @@ import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Card } from '@astryxdesign/core/Card'; import { Card } from '@astryxdesign/core/Card';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Tooltip } from '@astryxdesign/core/Tooltip';
@@ -42,8 +60,10 @@ import { TabList, Tab } from '@astryxdesign/core/TabList';
import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl'; import { SegmentedControl, SegmentedControlItem } from '@astryxdesign/core/SegmentedControl';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { Divider } from '@astryxdesign/core/Divider';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
@@ -107,6 +127,29 @@ const KPI_META = (summary) => [
{ key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 } { key: 'inactive', label: 'Inactive Riders', color: '#ef4444', icon: MdOutlineCancel, value: summary?.inactive ?? 0 }
]; ];
// TanStack Table v9 registers features explicitly, same pattern orders.js
// uses — only sorting/resizing/pagination ship here (no row-selection or
// column-visibility, since this table has no bulk-select flow).
const RIDERS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const RIDERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's table uses.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip // Inline stat chip used in the rider-logs collapse row. Mirrors the StatChip
// pattern from the pricing page so the telemetry block reads at a glance. // pattern from the pricing page so the telemetry block reads at a glance.
const LogChip = ({ color, icon: Icon, label, value }) => ( const LogChip = ({ color, icon: Icon, label, value }) => (
@@ -182,15 +225,17 @@ const Riders = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const isMobile = useIsNarrowViewport(); const isMobile = useIsNarrowViewport();
const loadMoreRef = useRef();
const [searchword, setSearchword] = useState(''); const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [locaName, setLocoName] = useState('All'); const [locaName, setLocoName] = useState('All');
const [appId, setAppId] = useState(0); const [appId, setAppId] = useState(0);
const [tabvalue, setTabvalue] = useState(0); const [tabvalue, setTabvalue] = useState(0);
const roleid = localStorage.getItem('roleid'); const roleid = localStorage.getItem('roleid');
const totalCols = roleid == 1 ? 11 : 10;
const [logsRow, setLogsRow] = useState(null); const [logsRow, setLogsRow] = useState(null);
// Table sorting/resizing/pagination state — mirrors orders.js's slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const [riderLogsdata, setRiderLogsdata] = useState(null); const [riderLogsdata, setRiderLogsdata] = useState(null);
const [historyDate, setHistoryDate] = useState(dayjs()); const [historyDate, setHistoryDate] = useState(dayjs());
const [historyStatus, setHistoryStatus] = useState('all'); const [historyStatus, setHistoryStatus] = useState('all');
@@ -320,62 +365,55 @@ const Riders = () => {
}; };
// ==============================|| fetchAllRiders||============================== // // ==============================|| fetchAllRiders||============================== //
// GET /admin/milers isn't paginated (fetchAllRiders always returns the full // GET /admin/milers isn't paginated — fetchAllRiders always returns the
// list in one shot) — getNextPageParam must not request a "next page" or it // full list in one shot (`{ details, nextPage: undefined }`). Previously
// re-fetches and appends the identical full list again, duplicating every // wrapped in useInfiniteQuery with a getNextPageParam that always returned
// rider in `rows`. // undefined, so "loading more" never actually fetched a second page — a
const { // plain bulk fetch, same shape orders.js uses for bookings, with the table
data: allRidersData, // below paginating the already-complete list client-side.
isLoading: allRidersLoading, const { data: allRidersResult, isLoading: allRidersLoading } = useQuery({
isFetchingNextPage,
fetchNextPage,
hasNextPage
} = useInfiniteQuery({
queryKey: ['allriders', appId, debouncedSearch, tabvalue], queryKey: ['allriders', appId, debouncedSearch, tabvalue],
queryFn: fetchAllRiders, queryFn: fetchAllRiders
getNextPageParam: () => undefined
}); });
const rows = (allRidersData?.pages.flatMap((page) => page.details || []) || []).filter(Boolean); const rows = useMemo(() => (allRidersResult?.details || []).filter(Boolean), [allRidersResult]);
// GET /admin/milers accepts no search/status params — debouncedSearch and // GET /admin/milers accepts no search/status params — debouncedSearch and
// tabvalue only trigger a refetch (via the queryKey) that returns the exact // tabvalue only trigger a refetch (via the queryKey) that returns the exact
// same full list every time. Search and the "Active" tab were both silently // same full list every time. Search and the "Active" tab were both silently
// no-ops until filtered client-side here. // no-ops until filtered client-side here.
const filteredRows = rows.filter((row) => { //
// "Active" here means currently online/working (Available/Assigned/ // Memoized (unlike the pre-TanStack version of this file) because the table
// On_Pickup) — Offline and Blocked (confirmed live enum) both mean not // reads this as `data`: TanStack memoizes its core row model on the `data`
// working. // REFERENCE and auto-resets `pageIndex` to 0 whenever that reference
const status = String(row.availabilitystatus || '').toLowerCase(); // changes. An unmemoized `.filter()` returns a new array every render —
if (tabvalue === 1 && (status === 'offline' || status === 'blocked')) return false; // including the very render a page-2/3/4 click causes — so pagination
if (!debouncedSearch) return true; // looked broken (every click snapped straight back to page 1). orders.js's
const q = debouncedSearch.toLowerCase(); // `filteredRows` was already memoized for the same reason.
return [row.displayname, row.phone].some((v) => const filteredRows = useMemo(
String(v || '') () =>
.toLowerCase() rows.filter((row) => {
.includes(q) // "Active" here means currently online/working (Available/Assigned/
); // On_Pickup) — Offline and Blocked (confirmed live enum) both mean
}); // not working.
const status = String(row.availabilitystatus || '').toLowerCase();
if (tabvalue === 1 && (status === 'offline' || status === 'blocked')) return false;
if (!debouncedSearch) return true;
const q = debouncedSearch.toLowerCase();
return [row.displayname, row.phone].some((v) =>
String(v || '')
.toLowerCase()
.includes(q)
);
}),
[rows, tabvalue, debouncedSearch]
);
// A tab switch or search re-scopes the list, so page 1 is the only page
// that still means anything — same reset orders.js does on its own table.
useEffect(() => { useEffect(() => {
if (!hasNextPage) return; setPagination((p) => ({ ...p, pageIndex: 0 }));
const observer = new IntersectionObserver( }, [tabvalue, debouncedSearch]);
(entries) => {
if (entries[0].isIntersecting) {
fetchNextPage();
}
},
{
root: document.querySelector('.dt-table-scroll'),
rootMargin: '0px',
threshold: 1.0
}
);
if (loadMoreRef.current) observer.observe(loadMoreRef.current);
return () => {
if (loadMoreRef.current) observer.unobserve(loadMoreRef.current);
};
}, [hasNextPage, fetchNextPage]);
// Per-row status meta — falls back to "unknown" if the rider's state key // Per-row status meta — falls back to "unknown" if the rider's state key
// isn't in the palette (e.g. brand-new status string from the backend). // isn't in the palette (e.g. brand-new status string from the backend).
@@ -501,6 +539,222 @@ const Riders = () => {
</HStack> </HStack>
); );
// The column model — everything a column IS (id, accessor, header, cell,
// width, sortability) declared once here instead of split across a
// hardcoded header row and a hand-written body row, same shape as
// orders.js's `columns`. The Action column is appended conditionally
// because it only exists for roleid 1, not hidden via a visibility slice.
const columns = useMemo(() => {
const base = [
{
id: 'serial',
size: 40,
minSize: 32,
maxSize: 48,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'id',
accessorKey: 'userid',
size: 90,
minSize: 70,
meta: { label: 'ID' },
header: () => <SectionLabel>ID</SectionLabel>,
cell: ({ getValue }) => (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
}}
>
#{getValue()}
</div>
)
},
{
id: 'rider',
accessorFn: (row) => row.displayname || row.authname || '—',
size: 200,
minSize: 160,
meta: { label: 'Rider' },
header: () => <SectionLabel>Rider</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary">
{row.original.phone || '—'}
</Text>
</VStack>
)
},
{
id: 'address',
accessorFn: (row) => row.suburb || row.address || '',
size: 220,
minSize: 160,
meta: { label: 'Address' },
header: () => <SectionLabel>Address</SectionLabel>,
cell: ({ row }) => (
<Tooltip content={row.original.address || ''}>
<VStack gap={0} padding={0}>
<Text type="supporting" weight="bold" color="secondary" maxLines={1}>
{row.original.suburb || (row.original.address ? row.original.address.slice(0, 20) + '…' : '—')}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.original.city || ''}
</Text>
</VStack>
</Tooltip>
)
},
{
id: 'vehicle',
accessorKey: 'defaultvehicletype',
size: 140,
minSize: 110,
meta: { label: 'Vehicle' },
header: () => <SectionLabel>Vehicle</SectionLabel>,
cell: ({ getValue }) => (
<HStack gap={1} vAlign="center">
<AccentAvatar color="#0ea5e9" size={22}>
<MdTwoWheeler size={12} />
</AccentAvatar>
<Text type="supporting" weight="bold">
{getValue() || '—'}
</Text>
</HStack>
)
},
{
id: 'shift',
accessorKey: 'shiftid',
size: 90,
minSize: 70,
meta: { label: 'Shift' },
header: () => <SectionLabel>Shift</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" weight="bold" color="secondary">
#{getValue() ?? '—'}
</Text>
)
},
{
id: 'time',
size: 130,
minSize: 110,
enableSorting: false,
meta: { label: 'Time', align: 'center' },
header: () => <SectionLabel>Time</SectionLabel>,
cell: ({ row }) => (
<VStack gap={0.5} padding={0} vAlign="center">
<Badge
variant="green"
label={row.original.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.original.starttime}`).format('hh:mm A') : '—'}
/>
<Badge
variant="red"
label={row.original.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.original.endtime}`).format('hh:mm A') : '—'}
/>
</VStack>
)
},
{
id: 'fare',
accessorKey: 'basefare',
size: 100,
minSize: 80,
meta: { label: 'Fare' },
header: () => <SectionLabel>Fare</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" weight="bold">
{getValue() ?? '—'}
</Text>
)
},
{
id: 'fuel',
accessorKey: 'fuelcharge',
size: 100,
minSize: 80,
meta: { label: 'Fuel' },
header: () => <SectionLabel>Fuel</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" weight="bold">
{getValue() ?? '—'}
</Text>
)
},
{
id: 'status',
accessorKey: 'availabilitystatus',
size: 130,
minSize: 110,
meta: { label: 'Status', align: 'center' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ row }) => {
const meta = getRowStatusMeta(row.original);
return <Badge variant={meta.badge} icon={<meta.icon size={12} />} label={meta.label} />;
}
}
];
if (roleid == 1) {
base.push({
id: 'action',
size: 150,
minSize: 120,
enableSorting: false,
enableResizing: false,
meta: { label: 'Action' },
header: () => <SectionLabel>Action</SectionLabel>,
cell: ({ row }) => <RowActions row={row.original} expanded={logsRow === row.original.milerprofileid} />
});
}
return base;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roleid, logsRow, pagination.pageIndex, pagination.pageSize]);
const table = useTable({
features: RIDERS_TABLE_FEATURES,
// The full filtered set — the table paginates it, same pattern
// orders.js uses for its (also client-filtered) bookings list.
data: filteredRows,
columns,
getRowId: (row, index) => String(row.userid ?? row.milerprofileid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered — the Action column only exists for roleid 1. Read off
// `columns` directly (not `table.getVisibleLeafColumns()`): that method
// only exists when columnVisibilityFeature is registered, which this
// table doesn't need (unlike orders.js — there is no column-hide menu here).
const colCount = columns.length;
return ( return (
<> <>
{(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && ( {(allRidersLoading || riderSummarysLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)) && (
@@ -532,19 +786,12 @@ const Riders = () => {
</KpiGrid> </KpiGrid>
<DataCard <DataCard
// History-only controls (status filter + date) get their own row —
// ONLY on that tab, so the other three tabs never show an empty
// toolbar strip above the tabs+search row.
toolbar={ toolbar={
<FilterToolbar max={tabvalue === 3 ? 3 : 1}> tabvalue === 3 ? (
<HStack justify="end"> <FilterToolbar max={2}>
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search riders (ctrl+k)"
/>
</VStack>
</HStack>
{tabvalue === 3 && (
<HStack gap={2} vAlign="center"> <HStack gap={2} vAlign="center">
<Text weight="bold" color="secondary"> <Text weight="bold" color="secondary">
Status: Status:
@@ -555,8 +802,6 @@ const Riders = () => {
))} ))}
</SegmentedControl> </SegmentedControl>
</HStack> </HStack>
)}
{tabvalue === 3 && (
<DateInput <DateInput
label="Select Date" label="Select Date"
isLabelHidden isLabelHidden
@@ -564,30 +809,45 @@ const Riders = () => {
value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined} value={historyDate ? dayjs(historyDate).format('YYYY-MM-DD') : undefined}
onChange={(v) => v && setHistoryDate(dayjs(v))} onChange={(v) => v && setHistoryDate(dayjs(v))}
/> />
)} </FilterToolbar>
</FilterToolbar> ) : undefined
} }
// Tabs and search share ONE row (same pattern orders.js uses) —
// previously the search box lived in its own toolbar row above the
// tab strip, which read as two stacked rows instead of one control
// strip.
tabs={ tabs={
<TabList value={String(tabvalue)} onChange={(v) => handleChangetab(Number(v))} size="sm"> <HStack justify="between" vAlign="center" gap={2} wrap="wrap">
{TAB_META.map((t) => { <TabList value={String(tabvalue)} onChange={(v) => handleChangetab(Number(v))} size="sm">
const Icon = t.icon; {TAB_META.map((t) => {
const count = const Icon = t.icon;
t.key === 2 const count =
? subRidersLogsData?.length || 0 t.key === 2
: t.key === 3 ? subRidersLogsData?.length || 0
? subHistoryData?.length || 0 : t.key === 3
: allRidersSummary?.[t.countKey] ?? 0; ? subHistoryData?.length || 0
return ( : allRidersSummary?.[t.countKey] ?? 0;
<Tab return (
key={t.key} <Tab
value={String(t.key)} key={t.key}
label={t.label} value={String(t.key)}
icon={<Icon size={15} />} label={t.label}
endContent={<Badge variant="neutral" label={count} />} icon={<Icon size={15} />}
/> endContent={<Badge variant="neutral" label={count} />}
); />
})} );
</TabList> })}
</TabList>
<VStack maxWidth={260} width="100%" padding={0} gap={0}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search riders (ctrl+k)"
/>
</VStack>
</HStack>
} }
> >
{tabvalue === 3 ? ( {tabvalue === 3 ? (
@@ -769,13 +1029,17 @@ const Riders = () => {
/> />
)} )}
{/* Same paginated + sorted row model the desktop table
reads, so a mobile viewer and a desktop viewer never see
a different page of riders for the same filters. */}
{filteredRows?.length !== 0 && {filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => { table.getRowModel().rows.map((tableRow, index) => {
const row = tableRow.original;
const statusMeta = getRowStatusMeta(row); const statusMeta = getRowStatusMeta(row);
const expanded = logsRow === row.milerprofileid; const expanded = logsRow === row.milerprofileid;
return ( return (
<MobileCard <MobileCard
key={row.userid ?? index} key={tableRow.id ?? index}
accent={statusMeta.color} accent={statusMeta.color}
selected={expanded} selected={expanded}
header={ header={
@@ -859,43 +1123,110 @@ const Riders = () => {
</MobileCard> </MobileCard>
); );
})} })}
{filteredRows?.length !== 0 && (
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage || hasNextPage ? (
<LoaderWithImage />
) : (
<Text type="supporting" weight="bold" color="secondary">
No more riders
</Text>
)}
</div>
)}
</MobileCardList> </MobileCardList>
) : ( ) : (
<TableScroll minWidth={1300}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's table.
Header and body are both rendered FROM the column
model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Riders"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>ID</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Rider</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Address</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Vehicle</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Shift</TableHeaderCell> return (
<TableHeaderCell style={{ textAlign: 'center' }}>Time</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>Fare</TableHeaderCell> key={header.id}
<TableHeaderCell>Fuel</TableHeaderCell> scope="col"
<TableHeaderCell style={{ textAlign: 'center' }}>Status</TableHeaderCell> aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
{roleid == 1 && <TableHeaderCell>Action</TableHeaderCell>} style={{
</TableRow> width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{allRidersLoading && <TableLoader rows={6} columns={totalCols} />} {allRidersLoading && <TableLoader rows={6} columns={colCount} />}
{filteredRows?.length === 0 && !allRidersLoading && ( {filteredRows?.length === 0 && !allRidersLoading && (
<TableRow> <TableRow>
<TableCell colSpan={totalCols}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No riders to show" title="No riders to show"
description={ description={
@@ -908,110 +1239,32 @@ const Riders = () => {
)} )}
{filteredRows?.length !== 0 && {filteredRows?.length !== 0 &&
filteredRows?.map((row, index) => { table.getRowModel().rows.map((tableRow) => {
const statusMeta = getRowStatusMeta(row); const row = tableRow.original;
const expanded = logsRow === row.milerprofileid; const expanded = logsRow === row.milerprofileid;
return ( return (
<Fragment key={row.userid ?? index}> <Fragment key={tableRow.id}>
<TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}> <TableRow style={{ backgroundColor: expanded ? tint(BRAND) : undefined }}>
<TableCell> {/* getAllCells (core), not getVisibleCells —
<Text type="supporting" weight="bold" color="secondary"> that one is gated behind
{String(index + 1).padStart(2, '0')} columnVisibilityFeature, which this table
</Text> doesn't register. */}
</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell> <TableCell
<div key={cell.id}
style={{ style={{
display: 'inline-flex', textAlign: cell.column.columnDef.meta?.align,
alignItems: 'center', maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
justifyContent: 'center',
padding: '3px 8px',
borderRadius: 999,
background: '#ffffff',
border: `1px solid ${edge(BRAND)}`,
color: BRAND,
fontWeight: 800,
fontSize: 11,
minWidth: 56
}} }}
> >
#{row?.userid} {flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
</TableCell>
<TableCell>
<VStack gap={0} padding={0} minWidth={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{row.displayname || row.authname || '—'}
</Text>
<Text type="supporting" color="secondary">
{row.phone || '—'}
</Text>
</VStack>
</TableCell>
<TableCell style={{ maxWidth: 220 }}>
<Tooltip content={row.address || ''}>
<VStack gap={0} padding={0}>
<Text type="supporting" weight="bold" color="secondary" maxLines={1}>
{row.suburb || (row.address ? row.address.slice(0, 20) + '…' : '—')}
</Text>
<Text type="supporting" color="secondary" maxLines={1}>
{row.city || ''}
</Text>
</VStack>
</Tooltip>
</TableCell>
<TableCell>
<HStack gap={1} vAlign="center">
<AccentAvatar color="#0ea5e9" size={22}>
<MdTwoWheeler size={12} />
</AccentAvatar>
<Text type="supporting" weight="bold">
{row.defaultvehicletype || '—'}
</Text>
</HStack>
</TableCell>
<TableCell>
<Text type="supporting" weight="bold" color="secondary">
#{row.shiftid ?? '—'}
</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<VStack gap={0.5} padding={0} vAlign="center">
<Badge
variant="green"
label={
row.starttime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.starttime}`).format('hh:mm A') : '—'
}
/>
<Badge
variant="red"
label={row.endtime ? dayjs(`${dayjs().format('MM-DD-YYYY')} ${row.endtime}`).format('hh:mm A') : '—'}
/>
</VStack>
</TableCell>
<TableCell>
<Text type="supporting" weight="bold">
{row.basefare ?? '—'}
</Text>
</TableCell>
<TableCell>
<Text type="supporting" weight="bold">
{row.fuelcharge ?? '—'}
</Text>
</TableCell>
<TableCell style={{ textAlign: 'center' }}>
<Badge variant={statusMeta.badge} icon={<statusMeta.icon size={12} />} label={statusMeta.label} />
</TableCell>
{roleid == 1 && (
<TableCell>
<RowActions row={row} expanded={expanded} />
</TableCell> </TableCell>
)} ))}
</TableRow> </TableRow>
{expanded && tabvalue !== 0 && ( {expanded && tabvalue !== 0 && (
<TableRow> <TableRow>
<TableCell colSpan={totalCols} style={{ padding: 12 }}> <TableCell colSpan={colCount} style={{ padding: 12 }}>
<TelemetryPanel row={row} riderLogsdata={riderLogsdata} /> <TelemetryPanel row={row} riderLogsdata={riderLogsdata} />
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -1019,26 +1272,39 @@ const Riders = () => {
</Fragment> </Fragment>
); );
})} })}
{filteredRows?.length !== 0 && (
<TableRow>
<TableCell colSpan={totalCols}>
<div ref={loadMoreRef} style={{ height: 40, textAlign: 'center' }}>
{isFetchingNextPage || hasNextPage ? (
<LoaderWithImage />
) : (
<Text type="supporting" weight="bold" color="secondary">
No more riders
</Text>
)}
</div>
</TableCell>
</TableRow>
)}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
)} )}
{/* Pagination replaces the old scroll-to-reveal sentinel — the
API returns the full rider list in one shot, so this was
always paginating an in-memory array, just without any UI
for it. Shared between mobile and desktop since both read
the same `table` row model above. */}
{filteredRows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
filteredRows.length
)} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={filteredRows.length}
pageSize={pagination.pageSize}
pageSizeOptions={RIDERS_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Riders pagination"
/>
</HStack>
</>
)}
</> </>
)} )}
</DataCard> </DataCard>

View File

@@ -1,17 +1,44 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md'; import {
MdOutlineLocalShipping,
MdAdd,
MdPlaylistAdd,
MdOutlineFlightTakeoff,
MdOutlineFlightLand,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { enqueueSnackbar } from 'notistack'; import { enqueueSnackbar } from 'notistack';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Divider } from '@astryxdesign/core/Divider';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import Loader from 'components/Loader'; import Loader from 'components/Loader';
@@ -43,12 +70,38 @@ const BRAND = DT.brand;
const emptyForm = { sourceHub: '', destinationHub: '', vehicle: '', driver: '' }; const emptyForm = { sourceHub: '', destinationHub: '', vehicle: '', driver: '' };
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const TRIPSHEETS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const Tripsheets = () => { const Tripsheets = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
const [itemDialogTrip, setItemDialogTrip] = useState(null); const [itemDialogTrip, setItemDialogTrip] = useState(null);
const [itemConsignmentId, setItemConsignmentId] = useState(''); const [itemConsignmentId, setItemConsignmentId] = useState('');
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets }); const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets });
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
@@ -132,6 +185,115 @@ const Tripsheets = () => {
const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value }));
// The column model — deliberately NOT wrapped in useMemo: the Source/
// Destination Hub columns' accessorFn closes over `hubMap`, and the
// Actions column closes over several mutations, so recomputing this small
// (5-column) array every render is simpler and cheaper than maintaining an
// exhaustive dependency list.
const columns = [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'sourceHub',
accessorFn: (row) => hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—'),
size: 220,
minSize: 160,
meta: { label: 'Source Hub' },
header: () => <SectionLabel>Source Hub</SectionLabel>,
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
},
{
id: 'destinationHub',
accessorFn: (row) => hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—'),
size: 220,
minSize: 160,
meta: { label: 'Destination Hub' },
header: () => <SectionLabel>Destination Hub</SectionLabel>,
cell: ({ getValue }) => <Text weight="semibold">{getValue()}</Text>
},
{
id: 'status',
accessorKey: 'status',
size: 160,
minSize: 120,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" color="secondary">
{getValue() || '—'}
</Text>
)
},
{
id: 'actions',
size: 170,
minSize: 150,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<IconButton
label="Add item"
tooltip="Add item"
size="sm"
variant="ghost"
icon={<MdPlaylistAdd size={16} />}
onClick={() => setItemDialogTrip(row.original)}
/>
<IconButton
label="Dispatch"
tooltip="Dispatch"
size="sm"
variant="ghost"
icon={<MdOutlineFlightTakeoff size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.original.tripsheetid })}
/>
<IconButton
label="Arrive"
tooltip="Arrive"
size="sm"
variant="ghost"
icon={<MdOutlineFlightLand size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.original.tripsheetid })}
/>
</HStack>
)
}
];
const table = useTable({
features: TRIPSHEETS_TABLE_FEATURES,
data: tripsheets,
columns,
getRowId: (row, index) => String(row.tripsheetid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />} {(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
@@ -150,22 +312,107 @@ const Tripsheets = () => {
</KpiGrid> </KpiGrid>
<DataCard> <DataCard>
<TableScroll minWidth={920}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Tripsheets"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Source Hub</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Destination Hub</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Status</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Actions</TableHeaderCell> const sorted = header.column.getIsSorted();
</TableRow> return (
<TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={5} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{tripsheets.length === 0 && !isLoading ? ( {tripsheets.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={colCount}>
<EmptyState <EmptyState
title="No tripsheets to show" title="No tripsheets to show"
description="Create a tripsheet to move consignments between hubs." description="Create a tripsheet to move consignments between hubs."
@@ -174,48 +421,49 @@ const Tripsheets = () => {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
tripsheets.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.tripsheetid || index}> <TableRow key={tableRow.id}>
<TableCell>{index + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell> <TableCell
<TableCell> key={cell.id}
{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')} style={{
</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell>{row.status || '—'}</TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
<TableCell> }}
<HStack gap={1} justify="end"> >
<IconButton {flexRender(cell.column.columnDef.cell, cell.getContext())}
label="Add item" </TableCell>
tooltip="Add item" ))}
size="sm"
variant="ghost"
icon={<MdPlaylistAdd size={16} />}
onClick={() => setItemDialogTrip(row)}
/>
<IconButton
label="Dispatch"
tooltip="Dispatch"
size="sm"
variant="ghost"
icon={<MdOutlineFlightTakeoff size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
/>
<IconButton
label="Arrive"
tooltip="Arrive"
size="sm"
variant="ghost"
icon={<MdOutlineFlightLand size={16} />}
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
{tripsheets.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
tripsheets.length
)} of ${tripsheets.length} tripsheet${tripsheets.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={tripsheets.length}
pageSize={pagination.pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Tripsheets pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

View File

@@ -1,17 +1,46 @@
import React, { useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd, MdBatteryFull } from 'react-icons/md'; import {
MdDirectionsCar,
MdEdit,
MdDeleteOutline,
MdAdd,
MdBatteryStd,
MdBatteryFull,
MdArrowUpward,
MdArrowDownward,
MdUnfoldMore
} from 'react-icons/md';
// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so
// sorting/resizing/pagination behave identically across the console's list
// pages.
import {
useTable,
flexRender,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
createPaginatedRowModel
} from '@tanstack/react-table';
import { Grid } from '@astryxdesign/core/Grid'; import { Grid } from '@astryxdesign/core/Grid';
import { HStack } from '@astryxdesign/core/HStack'; import { HStack } from '@astryxdesign/core/HStack';
import { VStack } from '@astryxdesign/core/VStack'; import { VStack } from '@astryxdesign/core/VStack';
import { Center } from '@astryxdesign/core/Center';
import { Text } from '@astryxdesign/core/Text'; import { Text } from '@astryxdesign/core/Text';
import { Button } from '@astryxdesign/core/Button'; import { Button } from '@astryxdesign/core/Button';
import { IconButton } from '@astryxdesign/core/IconButton'; import { IconButton } from '@astryxdesign/core/IconButton';
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
import { Divider } from '@astryxdesign/core/Divider';
import { TextInput } from '@astryxdesign/core/TextInput'; import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput'; import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector'; import { Selector } from '@astryxdesign/core/Selector';
import { EmptyState } from '@astryxdesign/core/EmptyState'; import { EmptyState } from '@astryxdesign/core/EmptyState';
import { Pagination } from '@astryxdesign/core/Pagination';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
@@ -29,6 +58,29 @@ import { TableScroll } from 'themes/dt/primitives';
const BRAND = DT.brand; const BRAND = DT.brand;
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v })); const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const VEHICLES_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
const VEHICLES_PAGE_SIZE_OPTIONS = [10, 25, 50, 100];
// Uppercase micro-label for the table's column headings — same convention
// orders.js's/riders.js's/Tenants.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
const emptyForm = { const emptyForm = {
vehicleid: null, vehicleid: null,
vehicleno: '', vehicleno: '',
@@ -51,6 +103,11 @@ const Vehicles = () => {
const [debouncedSearch, setDebouncedSearch] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState('');
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [form, setForm] = useState(emptyForm); const [form, setForm] = useState(emptyForm);
// Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's
// slices.
const [sorting, setSorting] = useState([]);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
const [columnSizing, setColumnSizing] = useState({});
const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles }); const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners }); const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
@@ -128,6 +185,12 @@ const Vehicles = () => {
if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid); if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid);
}; };
// A search change re-scopes the list, so page 1 is the only page that
// still means anything — same reset orders.js/riders.js do on their tables.
useEffect(() => {
setPagination((p) => ({ ...p, pageIndex: 0 }));
}, [debouncedSearch]);
const set = (key) => (value) => setForm((fm) => ({ ...fm, [key]: value })); const set = (key) => (value) => setForm((fm) => ({ ...fm, [key]: value }));
const handleSave = () => { const handleSave = () => {
@@ -148,6 +211,136 @@ const Vehicles = () => {
}); });
}; };
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as orders.js's/
// riders.js's/Tenants.js's `columns`.
const columns = useMemo(
() => [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1}
</Text>
)
},
{
id: 'vehicleno',
accessorKey: 'vehicleno',
size: 160,
minSize: 130,
meta: { label: 'Vehicle No' },
header: () => <SectionLabel>Vehicle No</SectionLabel>,
cell: ({ getValue }) => <Text weight="bold">{getValue()}</Text>
},
{
id: 'vehicletype',
accessorKey: 'vehicletype',
size: 130,
minSize: 100,
meta: { label: 'Type' },
header: () => <SectionLabel>Type</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'partner',
accessorFn: (row) => partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : ''),
size: 180,
minSize: 140,
meta: { label: 'Partner' },
header: () => <SectionLabel>Partner</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'maxweight',
accessorKey: 'maxweight',
size: 130,
minSize: 100,
meta: { label: 'Max Weight' },
header: () => <SectionLabel>Max Weight</SectionLabel>,
cell: ({ getValue }) => getValue() ?? '—'
},
{
id: 'battery',
accessorKey: 'batterypercentage',
size: 110,
minSize: 90,
meta: { label: 'Battery' },
header: () => <SectionLabel>Battery</SectionLabel>,
cell: ({ getValue }) => (getValue() != null ? `${getValue()}%` : '—')
},
{
id: 'status',
accessorKey: 'status',
size: 130,
minSize: 100,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => getValue() || '—'
},
{
id: 'actions',
size: 120,
minSize: 100,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => (
<HStack gap={1} justify="end">
<IconButton
label="Edit vehicle"
tooltip="Edit vehicle"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row.original)}
/>
<IconButton
label="Delete vehicle"
tooltip="Delete vehicle"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row.original)}
/>
</HStack>
)
}
],
// eslint-disable-next-line react-hooks/exhaustive-deps
[partnerMap, pagination.pageIndex, pagination.pageSize]
);
const table = useTable({
features: VEHICLES_TABLE_FEATURES,
// `rows` is already useMemo'd above (stable reference) — required for
// TanStack's row-model memoization to hold and for pagination clicks to
// stick. An unmemoized array here reads as "data changed" on every
// render and auto-resets to page 1.
data: rows,
columns,
getRowId: (row, index) => String(row.vehicleid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination, columnSizing },
onSortingChange: setSorting,
onPaginationChange: setPagination,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return ( return (
<> <>
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />} {(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
@@ -188,68 +381,154 @@ const Vehicles = () => {
</FilterToolbar> </FilterToolbar>
} }
> >
<TableScroll minWidth={960}> <TableScroll minWidth={table.getTotalSize()}>
<Table density="balanced" dividers="rows" hasHover> {/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's/
Tenants.js's tables. Header and body are both rendered FROM
the column model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Vehicles"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader> <TableHeader>
<TableRow> {table.getHeaderGroups().map((headerGroup) => (
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell> <TableRow key={headerGroup.id} isHeaderRow>
<TableHeaderCell>Vehicle No</TableHeaderCell> {headerGroup.headers.map((header) => {
<TableHeaderCell>Type</TableHeaderCell> const align = header.column.columnDef.meta?.align;
<TableHeaderCell>Partner</TableHeaderCell> const canSort = header.column.getCanSort();
<TableHeaderCell>Max Weight</TableHeaderCell> const sorted = header.column.getIsSorted();
<TableHeaderCell>Battery</TableHeaderCell> return (
<TableHeaderCell>Status</TableHeaderCell> <TableHeaderCell
<TableHeaderCell>Actions</TableHeaderCell> key={header.id}
</TableRow> scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{isLoading && <TableLoader rows={5} columns={8} />} {isLoading && <TableLoader rows={5} columns={colCount} />}
{rows.length === 0 && !isLoading ? ( {rows.length === 0 && !isLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={8}> <TableCell colSpan={colCount}>
<EmptyState title="No vehicles to show" icon={<MdDirectionsCar size={28} />} /> <EmptyState title="No vehicles to show" icon={<MdDirectionsCar size={28} />} />
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
rows.map((row, index) => ( table.getRowModel().rows.map((tableRow) => (
<TableRow key={row.vehicleid || index}> <TableRow key={tableRow.id}>
<TableCell>{index + 1}</TableCell> {tableRow.getAllCells().map((cell) => (
<TableCell> <TableCell
<Text weight="bold">{row.vehicleno}</Text> key={cell.id}
</TableCell> style={{
<TableCell>{row.vehicletype || '—'}</TableCell> textAlign: cell.column.columnDef.meta?.align,
<TableCell> maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
{partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')} }}
</TableCell> >
<TableCell>{row.maxweight ?? '—'}</TableCell> {flexRender(cell.column.columnDef.cell, cell.getContext())}
<TableCell>{row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}</TableCell> </TableCell>
<TableCell>{row.status || '—'}</TableCell> ))}
<TableCell>
<HStack gap={1} justify="end">
<IconButton
label="Edit vehicle"
tooltip="Edit vehicle"
size="sm"
variant="ghost"
icon={<MdEdit size={16} />}
onClick={() => openEdit(row)}
/>
<IconButton
label="Delete vehicle"
tooltip="Delete vehicle"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={16} />}
onClick={() => handleDelete(row)}
/>
</HStack>
</TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</TableScroll> </TableScroll>
{rows.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min(
(pagination.pageIndex + 1) * pagination.pageSize,
rows.length
)} of ${rows.length} vehicle${rows.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={pagination.pageIndex + 1}
onChange={(nextPage) => table.setPageIndex(nextPage - 1)}
totalItems={rows.length}
pageSize={pagination.pageSize}
pageSizeOptions={VEHICLES_PAGE_SIZE_OPTIONS}
onPageSizeChange={(size) => table.setPageSize(size)}
size="sm"
label="Vehicles pagination"
/>
</HStack>
</>
)}
</DataCard> </DataCard>
</PageShell> </PageShell>

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 Tripsheets = Loadable(lazy(() => import('pages/nearle/tripsheets/tripsheets')));
const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions'))); const Exceptions = Loadable(lazy(() => import('pages/nearle/exceptions/exceptions')));
const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel'))); const CompetitiveIntel = Loadable(lazy(() => import('pages/nearle/competitiveIntel/competitiveIntel')));
const Assistant = Loadable(lazy(() => import('pages/nearle/assistant/assistant')));
// ==============================|| MAIN ROUTING ||============================== // // ==============================|| MAIN ROUTING ||============================== //
@@ -168,6 +169,10 @@ const MainRoutes = {
{ {
path: 'competitive-intel', path: 'competitive-intel',
element: <CompetitiveIntel /> element: <CompetitiveIntel />
},
{
path: 'assistant',
element: <Assistant />
} }
] ]
}, },

46
src/utils/batchBucket.js Normal file
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", "profitability": "Profitability",
"dispatch": "Dispatch", "dispatch": "Dispatch",
"customers": "Customers", "customers": "Customers",
"assistant": "Assistant",
"fleetops": "Fleet & Ops", "fleetops": "Fleet & Ops",
"hubs": "Hubs", "hubs": "Hubs",
"vehicles": "Vehicles", "vehicles": "Vehicles",