diff --git a/src/menu-items/nearle.js b/src/menu-items/nearle.js index 5d58751..3523a75 100644 --- a/src/menu-items/nearle.js +++ b/src/menu-items/nearle.js @@ -32,7 +32,8 @@ import { TruckOutlined, WarningOutlined, RadarChartOutlined, - RiseOutlined + RiseOutlined, + RobotOutlined } from '@ant-design/icons'; // icons @@ -66,7 +67,8 @@ const icons = { TruckOutlined, WarningOutlined, RadarChartOutlined, - RiseOutlined + RiseOutlined, + RobotOutlined }; // ==============================|| MENU ITEMS - SUPPORT ||============================== // @@ -126,6 +128,13 @@ const nearle = { url: '/doormile/customers', icon: icons.ContactsOutlined }, + { + id: 'assistant', + title: , + type: 'item', + url: '/doormile/assistant', + icon: icons.RobotOutlined + }, { id: 'fleetops', title: , diff --git a/src/pages/api/api.js b/src/pages/api/api.js index 52c51c4..48cea6f 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -71,7 +71,11 @@ const BOOKING_STATUS_TO_DELIVERY_STATUS = { delivered: 'delivered', cancelled: 'cancelled' }; -const mapBookingStatusToDeliveryStatus = (status) => { +// Exported (not just module-local) so anything else that needs to classify a +// raw booking status — the operator bot's status-breakdown intent included — +// reuses this instead of growing a second copy of BOOKING_STATUS_TO_DELIVERY_STATUS +// that can drift from it. +export const mapBookingStatusToDeliveryStatus = (status) => { const key = String(status || '').toLowerCase(); return BOOKING_STATUS_TO_DELIVERY_STATUS[key] || key; }; @@ -636,9 +640,12 @@ export const fetchDeliveries = async ({ pageParam = 1, queryKey }) => { // meant an order re-stamped during the evening silently jumped out of the // batch it was actually assigned to and into whichever window contained // the current clock time — the same orders appearing under Afternoon and - // then Evening on the same day. Both now bucket on - // `expecteddeliverytime`, which is stable. It remains fine to DISPLAY - // this as a "last updated" stamp, which is all the reports use it for. + // then Evening on the same day. Both now bucket on `orderdate` (this + // row's `createdat`, immutable) instead — see dispatch/CLAUDE.md §1's + // table for why `expecteddeliverytime` was also tried and rejected (it's + // the promised delivery slot, not the wave the order was placed in). It + // remains fine to DISPLAY assigntime as a "last updated" stamp, which is + // all the reports use it for. assigntime: b.updatedat, orderstatus: mapBookingStatusToDeliveryStatus(b.status), droplat: b.deliverylatitude, diff --git a/src/pages/nearle/appUsers/appUsers.js b/src/pages/nearle/appUsers/appUsers.js index ce0face..0077fb3 100644 --- a/src/pages/nearle/appUsers/appUsers.js +++ b/src/pages/nearle/appUsers/appUsers.js @@ -1,19 +1,37 @@ -import { useMemo, useState } from 'react'; -import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } from 'react-icons/md'; +import { useEffect, useMemo, useState } from 'react'; +import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { enqueueSnackbar } from 'notistack'; +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so +// sorting/resizing/pagination behave identically across the console. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; + import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; +import { Divider } from '@astryxdesign/core/Divider'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { TextInput } from '@astryxdesign/core/TextInput'; import { Selector } from '@astryxdesign/core/Selector'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Pagination } from '@astryxdesign/core/Pagination'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import Loader from 'components/Loader'; @@ -47,12 +65,38 @@ const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' }; +// TanStack Table v9 registers features explicitly — only sorting/resizing/ +// pagination ship here (no row-selection or column-visibility, since this +// table has no bulk-select or column-hide flow). +const APP_USERS_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +const APP_USERS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's/riders.js's/Tenants.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + const AppUsers = () => { const queryClient = useQueryClient(); const [searchword, setSearchword] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [form, setForm] = useState(emptyForm); + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [columnSizing, setColumnSizing] = useState({}); const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers }); @@ -132,6 +176,129 @@ const AppUsers = () => { const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); + // A search change re-scopes the list, so page 1 is the only page that + // still means anything. + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })); + }, [debouncedSearch]); + + // The column model — everything a column IS declared once here instead of + // a hardcoded header row + hand-written body row, same shape as + // orders.js's/riders.js's/Tenants.js's `columns`. + const columns = useMemo( + () => [ + { + id: 'serial', + size: 40, + minSize: 32, + maxSize: 48, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + cell: ({ table: t, row }) => ( + + {pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1} + + ) + }, + { + id: 'name', + accessorKey: 'first_name', + size: 200, + minSize: 150, + meta: { label: 'Name' }, + header: () => Name, + cell: ({ getValue }) => {getValue()} + }, + { + id: 'email', + accessorKey: 'email', + size: 220, + minSize: 160, + meta: { label: 'Email' }, + header: () => Email, + cell: ({ getValue }) => getValue() + }, + { + id: 'phone', + accessorKey: 'phone', + size: 140, + minSize: 110, + meta: { label: 'Phone' }, + header: () => Phone, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'role', + accessorFn: (row) => roleLabel(row.role), + size: 130, + minSize: 100, + meta: { label: 'Role' }, + header: () => Role, + cell: ({ getValue }) => getValue() + }, + { + id: 'status', + accessorKey: 'status', + size: 120, + minSize: 100, + meta: { label: 'Status' }, + header: () => Status, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'actions', + size: 120, + minSize: 100, + enableSorting: false, + enableResizing: false, + meta: { label: 'Actions', align: 'right' }, + header: () => Actions, + cell: ({ row }) => ( + + } + onClick={() => openEdit(row.original)} + /> + } + onClick={() => handleDelete(row.original)} + /> + + ) + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [pagination.pageIndex, pagination.pageSize] + ); + + const table = useTable({ + features: APP_USERS_TABLE_FEATURES, + data: rows, + columns, + getRowId: (row, index) => String(row.id ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + onColumnSizingChange: setColumnSizing + }); + + // Skeleton rows and the empty state span exactly the columns that are + // actually rendered. Read off `columns.length` directly (not + // `table.getVisibleLeafColumns()`): that method only exists when + // columnVisibilityFeature is registered, which this table doesn't need. + const colCount = columns.length; + return ( <> {(isLoading || saveMutation.isPending || deleteMutation.isPending) && } @@ -170,24 +337,107 @@ const AppUsers = () => { } > - - + + {/* 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. */} +
- - # - Name - Email - Phone - Role - Status - Actions - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
+ + {rows.length > 0 && ( + <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + rows.length + )} of ${rows.length} user${rows.length === 1 ? '' : 's'}`} + + 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" + /> + + + )} diff --git a/src/pages/nearle/assistant/assistant.js b/src/pages/nearle/assistant/assistant.js new file mode 100644 index 0000000..fb662b7 --- /dev/null +++ b/src/pages/nearle/assistant/assistant.js @@ -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 ( + + + + + + } + emptyState={ +
+ + + Ask Doormile + + I answer from live data only — no guessing. Try: + + + {SUPPORTED_QUESTIONS.map((q) => ( + + • {q} + + ))} + + +
+ } + > + + {messages.map((m) => ( + + }> + + {m.text} + {m.detail && ( + + {m.detail} + + )} + + + {/* 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 && } + + ))} + +
+
+
+ ); +} diff --git a/src/pages/nearle/assistant/intents.js b/src/pages/nearle/assistant/intents.js new file mode 100644 index 0000000..ce442ee --- /dev/null +++ b/src/pages/nearle/assistant/intents.js @@ -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 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 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; +} diff --git a/src/pages/nearle/clientPricing/clientPricing.js b/src/pages/nearle/clientPricing/clientPricing.js index 5925508..50b63f5 100644 --- a/src/pages/nearle/clientPricing/clientPricing.js +++ b/src/pages/nearle/clientPricing/clientPricing.js @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { useMemo, useState, useEffect } from 'react'; import { MdLocalOffer, MdGroups, @@ -13,25 +13,46 @@ import { MdAdd, MdEdit, MdDeleteOutline, - MdCalculate + MdCalculate, + MdArrowUpward, + MdArrowDownward, + MdUnfoldMore } from 'react-icons/md'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { enqueueSnackbar } from 'notistack'; +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so +// sorting/resizing/pagination behave identically across the console. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; + import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Badge } from '@astryxdesign/core/Badge'; +import { Divider } from '@astryxdesign/core/Divider'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { NumberInput } from '@astryxdesign/core/NumberInput'; import { Selector } from '@astryxdesign/core/Selector'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Pagination } from '@astryxdesign/core/Pagination'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import Loader from 'components/Loader'; @@ -71,6 +92,29 @@ const MetricPill = ({ label }) => ( const CategoryChip = ({ icon, label }) => ; +// 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 }) => ( + + {children} + +); + const emptyForm = { pricingid: null, tenant: '', @@ -100,6 +144,11 @@ const ClientsPricing = () => { const [form, setForm] = useState(emptyForm); const [quoteOpen, setQuoteOpen] = useState(false); const [quoteForm, setQuoteForm] = useState({ tenant: '', location: '', vehicletype: 'Bike', weight: '', distance: '' }); + // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's + // slices. + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [columnSizing, setColumnSizing] = useState({}); const { data: pricing = [], isLoading } = useQuery({ queryKey: ['getallpricing', appId], @@ -151,6 +200,12 @@ const ClientsPricing = () => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [zonePricing, debouncedSearch, tenantMap, locationMap]); + // A zone/search change re-scopes the list, so page 1 is the only page that + // still means anything — same reset orders.js/riders.js do. + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })); + }, [appId, debouncedSearch]); + const stats = useMemo(() => { const total = zonePricing.length; const tenantsPriced = new Set(zonePricing.map((r) => r.tenantid).filter(Boolean)).size; @@ -281,6 +336,166 @@ const ClientsPricing = () => { const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); const setQuote = (key) => (value) => setQuoteForm((f) => ({ ...f, [key]: value })); + // The column model — everything a column IS declared once here instead of + // a hardcoded header row + hand-written body row, same shape as + // orders.js's/riders.js's/Tenants.js's `columns`. Tenant/Zone keep the + // widths from this file's earlier column-squeeze fix (Tenant needed real + // width so Zone's text didn't get crushed under `table-layout: auto`). + const columns = useMemo( + () => [ + { + id: 'serial', + size: 44, + minSize: 36, + maxSize: 52, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + cell: ({ table: t, row }) => ( + + {pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1} + + ) + }, + { + id: 'tenant', + accessorFn: (row) => tenantName(row), + size: 260, + minSize: 240, + meta: { label: 'Tenant' }, + header: () => Tenant, + cell: ({ getValue, row }) => ( + + + + + + + {getValue()} + + + ID #{row.original.pricingid} + + + + ) + }, + { + id: 'zone', + accessorFn: (row) => zoneName(row), + size: 180, + minSize: 160, + meta: { label: 'Zone' }, + header: () => Zone, + cell: ({ getValue }) => } label={getValue()} /> + }, + { + id: 'vehicle', + accessorKey: 'vehicletype', + size: 120, + minSize: 100, + meta: { label: 'Vehicle' }, + header: () => Vehicle, + cell: ({ getValue }) => } label={getValue() || '—'} /> + }, + { + id: 'baseprice', + accessorKey: 'baseprice', + size: 130, + minSize: 120, + meta: { label: 'Base Price' }, + header: () => Base Price, + cell: ({ getValue }) => + }, + { + id: 'priceperkm', + accessorKey: 'priceperkm', + size: 130, + minSize: 120, + meta: { label: 'Price / KM' }, + header: () => Price / KM, + cell: ({ getValue }) => + }, + { + id: 'priceperkg', + accessorKey: 'priceperkg', + size: 130, + minSize: 120, + meta: { label: 'Price / KG' }, + header: () => Price / KG, + cell: ({ getValue }) => + }, + { + id: 'handling', + accessorKey: 'handlingcharges', + size: 120, + minSize: 100, + meta: { label: 'Handling' }, + header: () => Handling, + cell: ({ getValue }) => + }, + { + id: 'status', + accessorKey: 'status', + size: 120, + minSize: 100, + meta: { label: 'Status' }, + header: () => Status, + cell: ({ getValue }) => } label={getValue() || '—'} /> + }, + { + id: 'actions', + size: 110, + minSize: 100, + enableSorting: false, + enableResizing: false, + meta: { label: 'Actions' }, + header: () => Actions, + cell: ({ row }) => ( + + + } + onClick={() => openEditDialog(row.original)} + /> + + + } + onClick={() => handleDelete(row.original)} + /> + + + ) + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [pagination.pageIndex, pagination.pageSize, tenantMap, locationMap] + ); + + const table = useTable({ + features: PRICING_TABLE_FEATURES, + // `rows` is already useMemo'd above (stable reference) — required for + // pagination clicks to stick, see riders.js's fix for the same bug. + data: rows, + columns, + getRowId: (row, index) => String(row.pricingid ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + onColumnSizingChange: setColumnSizing + }); + + const colCount = columns.length; + return ( <> {(isLoading || savePricingMutation.isPending || deletePricingMutation.isPending) && } @@ -344,98 +559,181 @@ const ClientsPricing = () => { /> ) : ( - {rows.map((row, index) => ( - - - - - - - - {tenantName(row)} - - - ID #{row.pricingid} - - - - - } - onClick={() => openEditDialog(row)} - /> - } - onClick={() => handleDelete(row)} - /> + {table.getRowModel().rows.map((tableRow) => { + const row = tableRow.original; + return ( + + + + + + + + {tenantName(row)} + + + ID #{row.pricingid} + + + + + } + onClick={() => openEditDialog(row)} + /> + } + onClick={() => handleDelete(row)} + /> + + } + > + + } label={zoneName(row)} /> + } label={row.vehicletype || '—'} /> - } - > - - } label={zoneName(row)} /> - } label={row.vehicletype || '—'} /> - - - - - - - - - - - - - - - - - - {row.status || '—'} - - - - - ))} + + + + + + + + + + + + + + + + + {row.status || '—'} + + + + + ); + })} ) ) : ( - - + + {/* 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. */} +
- - # - Tenant - Zone - Vehicle - Base Price - Price / KM - Price / KG - Handling - Status - Actions - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
)} + + {rows.length > 0 && ( + <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + rows.length + )} of ${rows.length} pricing rule${rows.length === 1 ? '' : 's'}`} + + table.setPageIndex(p - 1)} + totalItems={rows.length} + pageSize={pagination.pageSize} + pageSizeOptions={PAGE_SIZE_OPTIONS} + onPageSizeChange={(size) => table.setPageSize(size)} + size="sm" + label="Pricing pagination" + /> + + + )} diff --git a/src/pages/nearle/clients/Tenants.js b/src/pages/nearle/clients/Tenants.js index 8a19674..05117f6 100644 --- a/src/pages/nearle/clients/Tenants.js +++ b/src/pages/nearle/clients/Tenants.js @@ -24,18 +24,45 @@ import { MdEdit, MdDeleteOutline, MdPeopleAlt, - MdVisibility, - MdVisibilityOff, - MdBlock, - MdClose + MdArrowUpward, + MdArrowDownward, + MdUnfoldMore, + // Outline-only set for the Actions column — kept separate from the filled + // icons above (MdPlace/MdEdit/MdPeopleAlt), which stay filled for the + // Locations/Customers/Pricing dialogs elsewhere in this file. Mixing + // filled and outlined glyphs in one row is what read as inconsistent. + MdOutlineBlock, + MdOutlineVisibility, + MdOutlineVisibilityOff, + MdOutlineEdit, + MdOutlineClose, + MdOutlineMoreVert, + MdOutlinePlace, + MdOutlinePeopleAlt } from 'react-icons/md'; import { LuMail } from 'react-icons/lu'; import { BiUser } from 'react-icons/bi'; +// TanStack Table v9 — same engine orders.js/riders.js use, so sorting/ +// resizing/pagination behave identically across the console's list pages. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; + import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -46,6 +73,7 @@ import { NumberInput } from '@astryxdesign/core/NumberInput'; import { Selector } from '@astryxdesign/core/Selector'; import { TabList, Tab } from '@astryxdesign/core/TabList'; import { Pagination } from '@astryxdesign/core/Pagination'; +import { DropdownMenu } from '@astryxdesign/core/DropdownMenu'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { EmptyState } from '@astryxdesign/core/EmptyState'; @@ -103,6 +131,27 @@ const KPI_META = [ const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v })); +// TanStack Table v9 registers features explicitly — only sorting/resizing/ +// pagination ship here (no row-selection or column-visibility, since this +// table has no bulk-select or column-hide flow). +const TENANTS_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's and riders.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + // ==============================|| Tenant locations (branches/sites) dialog ||============================== // // Self-contained — deliberately does not touch the existing openRowIndex1/2 + // Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT @@ -547,6 +596,14 @@ const Clients1 = () => { const [latlong, setLatlong] = useState({}); const [editClient, setEditClient] = useState({}); const [page, setPage] = useState(0); + // Table sorting/resizing state — mirrors orders.js's/riders.js's slices. + // Pagination itself stays on the existing page/rowsPerPage state (below) + // rather than a new `pagination` object, since page/rowsPerPage already + // feed the tenantList query key and several onClick handlers — bridged + // into TanStack's { pageIndex, pageSize } shape via paginationState/ + // handlePaginationChange further down. + const [sorting, setSorting] = useState([]); + const [columnSizing, setColumnSizing] = useState({}); const dialogclose = () => { setDialogopen(false); @@ -822,15 +879,43 @@ const Clients1 = () => { const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active; - const renderRowActions = (row, index, expanded, size = 'sm') => ( - + // Icon buttons only, kept to 3 max + one overflow menu: status action, + // view details, edit, then "More" (Locations/Customers — secondary, + // low-frequency actions) behind a kebab menu. + // + // Sizing: `size="md"` is Astryx's own 32px button height (confirmed in + // Button's sizeStyles — sm=28px/md=32px/lg=36px) and icon-only buttons are + // always square, so this is a native 32×32px button, not a manually forced + // one. Its border-radius is Astryx's own default --radius-element (8px — + // see the identical note in dispatch/Dispatch.css), so no radius override + // is needed either — both requirements were already the design system's + // own defaults once the size prop was corrected. + // + // Icon family: every icon here is the Material "outline" variant + // (MdOutline*). Mixing MdBlock/MdVisibility/MdEdit/MdClose/MdMoreVert + // (filled) with MdOutlineCheckCircle (outline) — the state before this + // pass — is what actually read as inconsistent, not the icons' size or + // position. MdPlace/MdEdit/MdPeopleAlt stay filled where they're used + // elsewhere in this file (Locations/Customers/Pricing dialogs) — only this + // row switches to the Outline set. + // + // Colour: the status-action slot (Inactivate/Approve/Activate — whichever + // one applies to the active tab) is the one action with real consequence + // for the tenant's account state, so it keeps the `destructive` (red- + // tinted) treatment across all three tab variants for a consistent, + // predictable "this is the serious one" signal. View/Edit/More stay + // `ghost` (transparent, neutral) — `primary` only kicks in on View/Edit + // when their panel is the one currently open, an existing state indicator + // preserved as-is, not a new style. + const renderRowActions = (row, index, expanded, size = 'md') => ( + {value0 == 0 && ( } + icon={} onClick={() => { setSelectedCustomer(row); setSelectedtenid(row.tenantid); @@ -846,8 +931,8 @@ const Clients1 = () => { label="Approve" tooltip="Approve" size={size} - variant="ghost" - icon={} + variant="destructive" + icon={} onClick={() => { setSelectedCustomer(row); setDialogopen(true); @@ -862,8 +947,8 @@ const Clients1 = () => { label="Activate" tooltip="Activate" size={size} - variant="ghost" - icon={} + variant="destructive" + icon={} onClick={() => { setSelectedCustomer(row); setSelectedtenid(row.tenantid); @@ -879,7 +964,7 @@ const Clients1 = () => { tooltip={expanded ? 'Hide details' : 'View details'} size={size} variant={expanded ? 'primary' : 'ghost'} - icon={expanded ? : } + icon={expanded ? : } onClick={() => { setSelectedCustomer(row); handleCollapseToggle1(index); @@ -887,29 +972,13 @@ const Clients1 = () => { setSelectedtenid(row.tenantid); }} /> - } - onClick={() => setLocationsDialogTenant(row)} - /> - } - onClick={() => setCustomersDialogTenant(row)} - /> {value0 !== 1 && ( : } + icon={openRowIndex2 === index ? : } onClick={() => { setSelectedCustomer(row); handleCollapseToggle2(index); @@ -919,6 +988,22 @@ const Clients1 = () => { }} /> )} + , + isIconOnly: true + }} + menuWidth={180} + placement="below" + items={[ + { label: 'Locations', icon: , onClick: () => setLocationsDialogTenant(row) }, + { label: 'Customers', icon: , onClick: () => setCustomersDialogTenant(row) } + ]} + /> ); @@ -1049,6 +1134,146 @@ const Clients1 = () => { ); + // 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: () => #, + cell: ({ table: t, row }) => ( + + {String(page * rowsPerPage + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')} + + ) + }, + { + id: 'status', + accessorKey: 'status', + size: 140, + minSize: 110, + meta: { label: 'Status' }, + header: () => Status, + cell: ({ getValue }) => { + const meta = STATUS_META[String(getValue() || '').toLowerCase()] || STATUS_META.active; + const Icon = meta.icon; + return } label={meta.label} />; + } + }, + { + id: 'client', + accessorKey: 'tenantname', + size: 260, + minSize: 200, + meta: { label: 'Client' }, + header: () => Client, + cell: ({ getValue, row }) => ( + + + + + + + {getValue()} + + + ID #{row.original.tenantid} + + + + ) + }, + { + id: 'contact', + accessorKey: 'primarycontact', + size: 220, + minSize: 160, + meta: { label: 'Contact' }, + header: () => Contact, + cell: ({ getValue, row }) => ( + + + {getValue() || '—'} + + + {row.original.primaryemail || '—'} + + + ) + }, + { + id: 'deliveryOtp', + accessorFn: (row) => (row.requiredeliveryotp ? 'Yes' : 'No'), + size: 150, + minSize: 130, + meta: { label: 'Delivery OTP', align: 'center' }, + header: () => Delivery OTP, + cell: ({ getValue }) => ( + + {getValue()} + + ) + }, + { + 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: () => Actions, + cell: ({ row }) => renderRowActions(row.original, row.original.tenantid, openRowIndex1 === row.original.tenantid) + } + ]; + + const table = useTable({ + features: TENANTS_TABLE_FEATURES, + // filteredTenantList is already useMemo'd above (stable reference across + // renders) — required for TanStack's row-model memoization to actually + // hold and for pagination clicks to stick (an unmemoized array here + // reads as "data changed" on every render and auto-resets to page 1, + // the exact bug fixed on riders.js's table). + data: filteredTenantList, + columns, + getRowId: (row, index) => String(row.tenantid ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination: paginationState, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: handlePaginationChange, + onColumnSizingChange: setColumnSizing + }); + + // Skeleton rows and the empty state span exactly the columns that are + // actually rendered. Read off `columns.length` directly (not + // `table.getVisibleLeafColumns()`): that method only exists when + // columnVisibilityFeature is registered, which this table doesn't need. + const colCount = columns.length; + return ( <> {(getalltenantsIsLoading || isloader) && } @@ -1140,24 +1365,108 @@ const Clients1 = () => { } > {!isMobile && ( - - + + {/* 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. */} +
- - # - Status - Client - Contact - Delivery OTP - Actions - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
. 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: () => #, + 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: () => Company, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'area', + accessorKey: 'area', + size: 160, + minSize: 120, + meta: { label: 'Area' }, + header: () => Area, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'phone', + accessorKey: 'phone', + size: 140, + minSize: 110, + meta: { label: 'Phone' }, + header: () => Phone, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'address', + accessorKey: 'address', + size: 240, + minSize: 160, + meta: { label: 'Address' }, + header: () => Address, + cell: ({ getValue }) => ( + + {getValue() || '—'} + + ) + }, + { + id: 'rate_per_kg', + accessorKey: 'rate_per_kg', + size: 120, + minSize: 100, + meta: { label: 'Rate/Kg' }, + header: () => Rate/Kg, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'actions', + size: 110, + minSize: 90, + enableSorting: false, + enableResizing: false, + meta: { label: 'Actions' }, + header: () => Actions, + cell: ({ row }) => ( + + } + onClick={() => openEdit(row.original)} + /> + } + onClick={() => handleDelete(row.original)} + /> + + ) + } + ] + : [ + { + id: 'serial', + size: 50, + minSize: 40, + maxSize: 60, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + 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: () => Company, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'weight_slab', + accessorKey: 'weight_slab', + size: 160, + minSize: 120, + meta: { label: 'Weight Slab' }, + header: () => Weight Slab, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'zone', + accessorKey: 'zone', + size: 160, + minSize: 120, + meta: { label: 'Zone' }, + header: () => Zone, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'rate', + accessorKey: 'rate', + size: 140, + minSize: 110, + meta: { label: 'Rate' }, + header: () => Rate, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'actions', + size: 110, + minSize: 90, + enableSorting: false, + enableResizing: false, + meta: { label: 'Actions' }, + header: () => Actions, + cell: ({ row }) => ( + + } + onClick={() => openEdit(row.original)} + /> + } + onClick={() => handleDelete(row.original)} + /> + + ) + } + ]; + + const table = useTable({ + features: COMPETITIVE_INTEL_TABLE_FEATURES, + data: tableRows, + columns, + manualPagination: true, + pageCount, + getRowId: (row, index) => String(row.id ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination: paginationState, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: handlePaginationChange, + onColumnSizingChange: setColumnSizing + }); + + const colCount = columns.length; + return ( <> {(isLoading || branchMutation.isPending || carrierMutation.isPending) && } @@ -259,35 +516,107 @@ const CompetitiveIntel = () => { } > - -
+ + {/* 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. */} +
- {tab === 'branches' ? ( - - # - Company - Area - Phone - Address - Rate/Kg - Actions + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
+ + {/* 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. */} +
- - # - Customer - Contact - Bookings - Actions - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
)} + + {rows.length > 0 && ( + <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + rows.length + )} of ${rows.length} customer${rows.length === 1 ? '' : 's'}`} + + 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" + /> + + + )} diff --git a/src/pages/nearle/exceptions/exceptions.js b/src/pages/nearle/exceptions/exceptions.js index eb89904..60feb79 100644 --- a/src/pages/nearle/exceptions/exceptions.js +++ b/src/pages/nearle/exceptions/exceptions.js @@ -1,20 +1,35 @@ import { useMemo, useState } from 'react'; -import { MdWarningAmber, MdAdd, MdCheckCircleOutline } from 'react-icons/md'; +import { MdWarningAmber, MdAdd, MdCheckCircleOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; import { enqueueSnackbar } from 'notistack'; import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog'; import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; +import { Divider } from '@astryxdesign/core/Divider'; import { TextInput } from '@astryxdesign/core/TextInput'; import { TextArea } from '@astryxdesign/core/TextArea'; import { Selector } from '@astryxdesign/core/Selector'; import { Badge } from '@astryxdesign/core/Badge'; +import { Pagination } from '@astryxdesign/core/Pagination'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; @@ -30,6 +45,29 @@ import { TableScroll, AccentAvatar } from 'themes/dt/primitives'; const BRAND = DT.brand; +// TanStack Table v9 registers features explicitly — only sorting/resizing/ +// pagination ship here (no row-selection or column-visibility, matching +// orders.js/riders.js/Tenants.js's tables). +const EXCEPTIONS_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's/riders.js's/Tenants.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable']; const EXCEPTION_TYPE_OPTIONS = EXCEPTION_TYPES.map((t) => ({ value: t, label: t.replace(/_/g, ' ') })); const SEVERITIES = ['Low', 'Medium', 'High', 'Critical']; @@ -51,6 +89,11 @@ const Exceptions = () => { const [resolveRow, setResolveRow] = useState(null); const [resolution, setResolution] = useState(''); const [resolveStatus, setResolveStatus] = useState('Resolved'); + // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's + // slices. + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [columnSizing, setColumnSizing] = useState({}); const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions }); const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); @@ -123,6 +166,132 @@ const Exceptions = () => { const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); + // The column model — everything a column IS declared once here instead of + // a hardcoded header row + hand-written body row, same shape as orders.js's + // / riders.js's / Tenants.js's `columns`. + const columns = useMemo( + () => [ + { + id: 'serial', + size: 44, + minSize: 36, + maxSize: 52, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + cell: ({ table: t, row }) => ( + + {pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1} + + ) + }, + { + id: 'consignment', + accessorKey: 'consignmentid', + size: 170, + minSize: 130, + meta: { label: 'Consignment' }, + header: () => Consignment, + cell: ({ getValue }) => {getValue()} + }, + { + id: 'hub', + accessorFn: (row) => hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—'), + size: 150, + minSize: 120, + meta: { label: 'Hub' }, + header: () => Hub, + cell: ({ getValue }) => {getValue()} + }, + { + id: 'type', + accessorKey: 'exceptiontype', + size: 150, + minSize: 120, + meta: { label: 'Type' }, + header: () => Type, + cell: ({ getValue }) => {getValue()} + }, + { + id: 'severity', + accessorKey: 'severity', + size: 130, + minSize: 110, + meta: { label: 'Severity' }, + header: () => Severity, + cell: ({ getValue }) => + }, + { + id: 'description', + accessorKey: 'description', + size: 260, + minSize: 180, + meta: { label: 'Description' }, + header: () => Description, + cell: ({ getValue }) => ( + + {getValue()} + + ) + }, + { + id: 'status', + accessorFn: (row) => row.status || 'Open', + size: 130, + minSize: 110, + meta: { label: 'Status' }, + header: () => Status, + cell: ({ getValue }) => {getValue()} + }, + { + id: 'actions', + size: 140, + minSize: 110, + enableSorting: false, + enableResizing: false, + meta: { label: 'Actions' }, + header: () => Actions, + cell: ({ row }) => + !['Resolved', 'Closed'].includes(row.original.status) ? ( + + } + onClick={() => setResolveRow(row.original)} + /> + + ) : null + } + ], + [hubMap, pagination.pageIndex, pagination.pageSize] + ); + + const table = useTable({ + features: EXCEPTIONS_TABLE_FEATURES, + // `rows` is already useMemo'd above (stable reference across renders) — + // required for TanStack's row-model memoization to hold and for + // pagination clicks to stick (an unmemoized array reads as "data + // changed" on every render and auto-resets to page 1). + data: rows, + columns, + getRowId: (row, index) => String(row.exceptionid ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + onColumnSizingChange: setColumnSizing + }); + + // Skeleton rows and the empty state span exactly the columns that are + // actually rendered. Read off `columns.length` directly (not + // `table.getVisibleLeafColumns()`): that method only exists when + // columnVisibilityFeature is registered, which this table doesn't need. + const colCount = columns.length; + return ( <> {(isLoading || createMutation.isPending || resolveMutation.isPending) && } @@ -163,25 +332,107 @@ const Exceptions = () => { } > - - + + {/* 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. */} +
- - # - Consignment - Hub - Type - Severity - Description - Status - Actions - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
+ + {rows.length > 0 && ( + <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + rows.length + )} of ${rows.length} exception${rows.length === 1 ? '' : 's'}`} + + table.setPageIndex(nextPage - 1)} + totalItems={rows.length} + pageSize={pagination.pageSize} + pageSizeOptions={PAGE_SIZE_OPTIONS} + onPageSizeChange={(size) => table.setPageSize(size)} + size="sm" + label="Exceptions pagination" + /> + + + )} diff --git a/src/pages/nearle/hubs/hubs.js b/src/pages/nearle/hubs/hubs.js index 11eb430..cff6144 100644 --- a/src/pages/nearle/hubs/hubs.js +++ b/src/pages/nearle/hubs/hubs.js @@ -1,11 +1,36 @@ -import React, { useMemo, useState } from 'react'; -import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace, MdOutlineCheckCircle } from 'react-icons/md'; +import React, { useEffect, useMemo, useState } from 'react'; +import { + MdOutlineWarehouse, + MdEdit, + MdDeleteOutline, + MdAdd, + MdPlace, + MdOutlineCheckCircle, + MdArrowUpward, + MdArrowDownward, + MdUnfoldMore +} from 'react-icons/md'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { enqueueSnackbar } from 'notistack'; +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so +// sorting/resizing/pagination behave identically across the console. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -16,6 +41,7 @@ import { TextInput } from '@astryxdesign/core/TextInput'; import { NumberInput } from '@astryxdesign/core/NumberInput'; import { Selector } from '@astryxdesign/core/Selector'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Pagination } from '@astryxdesign/core/Pagination'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import Loader from 'components/Loader'; @@ -41,6 +67,29 @@ const STATUS_OPTIONS = [ { value: 'Inactive', label: 'Inactive' } ]; +// TanStack Table v9 registers features explicitly — only sorting/resizing/ +// pagination ship here (no row-selection or column-visibility, since this +// table has no bulk-select or column-hide flow). +const HUBS_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +const HUBS_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's/riders.js's/Tenants.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + const emptyForm = { hubid: null, hubname: '', @@ -63,6 +112,11 @@ const Hubs = () => { const [debouncedSearch, setDebouncedSearch] = useState(''); const [dialogOpen, setDialogOpen] = useState(false); const [form, setForm] = useState(emptyForm); + // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's + // /Tenants.js's slices. + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [columnSizing, setColumnSizing] = useState({}); const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs }); @@ -156,6 +210,138 @@ const Hubs = () => { const set = (key) => (value) => setForm((f) => ({ ...f, [key]: value })); + // A search change re-scopes the list, so page 1 is the only page that + // still means anything. + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })); + }, [debouncedSearch]); + + // The column model — everything a column IS declared once here instead of + // a hardcoded header row + hand-written body row, same shape as orders.js's + // /riders.js's/Tenants.js's `columns`. + const columns = useMemo( + () => [ + { + id: 'serial', + size: 48, + minSize: 40, + maxSize: 56, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + cell: ({ table: t, row }) => ( + + {pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1} + + ) + }, + { + id: 'hubname', + accessorKey: 'hubname', + size: 220, + minSize: 160, + meta: { label: 'Hub Name' }, + header: () => Hub Name, + cell: ({ getValue }) => {getValue()} + }, + { + id: 'hubtype', + accessorFn: (row) => (row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'), + size: 150, + minSize: 120, + meta: { label: 'Type' }, + header: () => Type, + cell: ({ getValue }) => getValue() + }, + { + id: 'city', + accessorKey: 'applocationid', + size: 100, + minSize: 80, + meta: { label: 'City' }, + header: () => City, + cell: ({ getValue }) => getValue() ?? '—' + }, + { + id: 'address', + accessorKey: 'address', + size: 240, + minSize: 160, + meta: { label: 'Address' }, + header: () => Address, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'pincode', + accessorKey: 'pincode', + size: 110, + minSize: 90, + meta: { label: 'Pincode' }, + header: () => Pincode, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'status', + accessorKey: 'status', + size: 130, + minSize: 110, + meta: { label: 'Status' }, + header: () => Status, + cell: ({ getValue }) => (getValue() ? : '—') + }, + { + id: 'actions', + size: 120, + minSize: 100, + enableSorting: false, + enableResizing: false, + meta: { label: 'Actions', align: 'right' }, + header: () => Actions, + cell: ({ row }) => ( + + } + onClick={() => openEdit(row.original)} + /> + } + onClick={() => handleDelete(row.original)} + /> + + ) + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [pagination.pageIndex, pagination.pageSize] + ); + + const table = useTable({ + features: HUBS_TABLE_FEATURES, + data: rows, + columns, + getRowId: (row, index) => String(row.hubid ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + onColumnSizingChange: setColumnSizing + }); + + // Skeleton rows and the empty state span exactly the columns that are + // actually rendered. Read off `columns.length` directly (not + // `table.getVisibleLeafColumns()`): that method only exists when + // columnVisibilityFeature is registered, which this table doesn't need. + const colCount = columns.length; + return ( <> {(isLoading || deleteMutation.isPending) && } @@ -196,25 +382,107 @@ const Hubs = () => { } > - - + + {/* 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. */} +
- - # - Hub Name - Type - City - Address - Pincode - Status - Actions - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
+ + {rows.length > 0 && ( + <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + rows.length + )} of ${rows.length} hub${rows.length === 1 ? '' : 's'}`} + + 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" + /> + + + )} diff --git a/src/pages/nearle/orders/OrdersPreview.js b/src/pages/nearle/orders/OrdersPreview.js index 5ebfb52..57954b6 100644 --- a/src/pages/nearle/orders/OrdersPreview.js +++ b/src/pages/nearle/orders/OrdersPreview.js @@ -2,13 +2,29 @@ import { Fragment, useEffect, useMemo, useState } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; import { useMutation, useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; -import { MdDirectionsBike, MdArrowBack } from 'react-icons/md'; +import { MdDirectionsBike, MdArrowBack, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { FaMapLocationDot } from 'react-icons/fa6'; import { HiOutlineArrowLeft } from 'react-icons/hi'; +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so +// sorting/resizing behave identically across the console's list pages. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; + import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -28,7 +44,7 @@ import { fetchPaymentType, fetchRidersList, finalCreatedeliveries, notifyRider } import { OpenToast } from 'components/third-party/OpenToast'; import Loader from 'components/Loader'; import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport'; -import { STATUS } from 'themes/dt/tokens'; +import { DT, STATUS } from 'themes/dt/tokens'; import { TableScroll } from 'themes/dt/primitives'; var utc = require('dayjs/plugin/utc'); @@ -36,125 +52,154 @@ dayjs.extend(utc); const orderTypeBadge = (type) => (type === 'Economy' ? 'green' : type === 'Risky' ? 'error' : 'blue'); +// TanStack Table v9 registers features explicitly — sorting/resizing ship +// here like every other converted page. rowPaginationFeature is registered +// too (table.getRowModel() always resolves through getPaginatedRowModel() +// internally), but this page deliberately never surfaces pagination UI — +// see the `pagination` state below for why. +const ORDERS_PREVIEW_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's/riders.js's/Tenants.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + // Mobile-only rendering of the optimised-orders preview. Mirrors the exact same // fields, chips and tooltips as the desktop table rows — no behaviour added, // purely a card layout for phones. Renders for both aiMode 1 and normal mode; // the Zone / Rider fields are gated on aiMode just like the table columns. -const MobileOrdersList = ({ list, aiMode }) => { - if (!list || list.length === 0) { +// Reads the same sorted `table` row model the desktop table does, so a +// sort applied before switching to a narrow viewport isn't lost. +const MobileOrdersList = ({ table, aiMode }) => { + const rows = table.getRowModel().rows; + if (!rows || rows.length === 0) { return } />; } return ( - {list.map((val, index) => ( - - - - #{index + 1} - - {aiMode == 1 && } + {rows.map((tableRow, index) => { + const val = tableRow.original; + return ( + + + + #{index + 1} + + {aiMode == 1 && } + + - -
- } - > - - - - - {val.tenantname} - - {val.tenantsuburb} - - - {val.applocation} - - - - - - - - {`${val.locationname}-(${val.locationsuburb})`} - - - - {val.orderid} - - - - + } + > + + + - {parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')} - {parseDoormileTimestamp(val.orderdate).format('hh:mm A')} + {val.tenantname} + + {val.tenantsuburb} + + + {val.applocation} + - - - - - {parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')} - {parseDoormileTimestamp(val.deliverydate).format('hh:mm A')} - - - - - - - - {val.pickupcustomer} - {val.pickupcontactno} - - {val.pickupsuburb || val.pickupaddress.slice(0, 20)} - - - - - - - {val.deliverycustomer} - {val.deliverycontactno} - - {val.deliverysuburb || val.deliveryaddress.slice(0, 20)} - - - - - {val.ordernotes ? : null} - - {aiMode == 1 && ( - - {val.username} - ID : {val.userid} - )} - - - - + + + {`${val.locationname}-(${val.locationsuburb})`} - - + + + {val.orderid} + - - + + + + {parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')} + {parseDoormileTimestamp(val.orderdate).format('hh:mm A')} + + + - + + + {parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')} + {parseDoormileTimestamp(val.deliverydate).format('hh:mm A')} + + + + - - - - - - - - - - - - - ))} + + + {val.pickupcustomer} + {val.pickupcontactno} + + {val.pickupsuburb || val.pickupaddress.slice(0, 20)} + + + + + + + {val.deliverycustomer} + {val.deliverycontactno} + + {val.deliverysuburb || val.deliveryaddress.slice(0, 20)} + + + + + {val.ordernotes ? : null} + + {aiMode == 1 && ( + + {val.username} + ID : {val.userid} + + )} + + + + + + + + + + + + + + + + + + + + + + + + + ); + })} ); }; @@ -167,6 +212,14 @@ const OrdersPreview = () => { const [payment, setPayment] = useState(null); const [finaldeliveryList, setFinalDeliveryList] = useState([]); const [isLoading, setIsLoading] = useState(false); + // Sorting/resizing state — mirrors orders.js's/riders.js's slices. + // pageSize: Infinity keeps every row on one "page" (no Pagination UI): + // this is a pre-commit REVIEW step for a single solver run's batch, and + // the operator needs to see every affected order before clicking + // Finalise, not just whatever page happens to be showing. + const [sorting, setSorting] = useState([]); + const [pagination] = useState({ pageIndex: 0, pageSize: Infinity }); + const [columnSizing, setColumnSizing] = useState({}); const deliverylist = location.state?.deliverylist; const zoneData = location.state?.zoneData; const metaData = location.state?.metaData; @@ -266,106 +319,225 @@ const OrdersPreview = () => { }); }; - const renderRow = (val, index) => ( - - - - {index + 1} - - {aiMode == 1 && ( - - - - )} - - + // The column model — everything a column IS declared once here instead of + // a hand-written body row, same shape as orders.js's/riders.js's/ + // Tenants.js's `columns`. Zone/Rider are pushed in conditionally, matching + // the exact `aiMode == 1` gate the header row used before. + const columns = useMemo(() => { + const base = [ + { + id: 'serial', + size: 44, + minSize: 36, + maxSize: 52, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + cell: ({ table: t, row }) => {t.getRowModel().rows.indexOf(row) + 1} + } + ]; + + if (aiMode == 1) { + base.push({ + id: 'zone', + accessorKey: 'zone_name', + size: 110, + minSize: 90, + meta: { label: 'Zone' }, + header: () => Zone, + cell: ({ getValue }) => + }); + } + + base.push( + { + id: 'tenant', + accessorKey: 'tenantname', + size: 220, + minSize: 170, + meta: { label: 'Tenant' }, + header: () => Tenant, + cell: ({ getValue, row }) => ( + - {val.tenantname} + {getValue()} - {val.tenantsuburb} + {row.original.tenantsuburb} - {val.applocation} + {row.original.applocation} - - - - {`${val.locationname}-(${val.locationsuburb})`} - - - - {val.orderid} - - - - - - {parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')} - {parseDoormileTimestamp(val.orderdate).format('hh:mm A')} - - - - - - - {parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')} - {parseDoormileTimestamp(val.deliverydate).format('hh:mm A')} - - - - - + ) + }, + { + id: 'orderLocation', + accessorKey: 'locationname', + size: 240, + minSize: 190, + meta: { label: 'Order Location' }, + header: () => Order Location, + cell: ({ row }) => { + const val = row.original; + return ( + <> + + {`${val.locationname}-(${val.locationsuburb})`} + + + + {val.orderid} + + + + + + {parseDoormileTimestamp(val.orderdate).format('DD/MM/YYYY')} + {parseDoormileTimestamp(val.orderdate).format('hh:mm A')} + + + - + + + {parseDoormileTimestamp(val.deliverydate).format('DD/MM/YYYY')} + {parseDoormileTimestamp(val.deliverydate).format('hh:mm A')} + + + + + ); + } + }, + { + id: 'pickup', + accessorKey: 'pickupcustomer', + size: 200, + minSize: 160, + meta: { label: 'Pickup' }, + header: () => Pickup, + cell: ({ getValue, row }) => ( - {val.pickupcustomer} - {val.pickupcontactno} - - {val.pickupsuburb || val.pickupaddress.slice(0, 20)} + {getValue()} + {row.original.pickupcontactno} + + {row.original.pickupsuburb || row.original.pickupaddress.slice(0, 20)} - - + ) + }, + { + id: 'delivery', + accessorKey: 'deliverycustomer', + size: 200, + minSize: 160, + meta: { label: 'Delivery' }, + header: () => Delivery, + cell: ({ getValue, row }) => ( - {val.deliverycustomer} - {val.deliverycontactno} - - {val.deliverysuburb || val.deliveryaddress.slice(0, 20)} + {getValue()} + {row.original.deliverycontactno} + + {row.original.deliverysuburb || row.original.deliveryaddress.slice(0, 20)} - - {val.ordernotes} - {aiMode == 1 && ( - - {val.username} - ID : {val.userid} - - )} - - - - + ) + }, + { + id: 'notes', + accessorKey: 'ordernotes', + size: 160, + minSize: 120, + meta: { label: 'Notes' }, + header: () => Notes, + cell: ({ getValue }) => {getValue()} + } + ); + + if (aiMode == 1) { + base.push({ + id: 'rider', + accessorKey: 'username', + size: 150, + minSize: 120, + meta: { label: 'Rider' }, + header: () => Rider, + cell: ({ getValue, row }) => ( + <> + {getValue()} + ID : {row.original.userid} + + ) + }); + } + + base.push( + { + id: 'type', + accessorKey: 'ordertype', + size: 110, + minSize: 90, + meta: { label: 'Type', align: 'center' }, + header: () => Type, + cell: ({ getValue }) => + }, + { + id: 'profit', + accessorKey: 'deliveryamt', + size: 150, + minSize: 120, + meta: { label: 'Profit', align: 'center' }, + header: () => Profit, + cell: ({ row }) => ( - + - + - - + ) + }, + { + id: 'kms', + accessorKey: 'kms', + size: 150, + minSize: 120, + meta: { label: 'Kms', align: 'center' }, + header: () => Kms, + cell: ({ getValue, row }) => ( - + - + - - - - ); + ) + } + ); + + return base; + }, [aiMode]); + + const table = useTable({ + features: ORDERS_PREVIEW_TABLE_FEATURES, + // finaldeliveryList is already component state (stable reference between + // renders unless setFinalDeliveryList is called) — required for + // TanStack's row-model memoization to hold correctly. + data: finaldeliveryList, + columns, + getRowId: (row, index) => String(row.orderid ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination, columnSizing }, + onSortingChange: setSorting, + onColumnSizingChange: setColumnSizing + }); + + const colCount = columns.length; return ( <> @@ -405,34 +577,129 @@ const OrdersPreview = () => { {isMobile ? ( - + ) : ( - - + + {/* 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. */} +
- - # - {aiMode == 1 && Zone} - Tenant - Order Location - Pickup - Delivery - Notes - {aiMode == 1 && Rider} - Type - Profit - KMS - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
diff --git a/src/pages/nearle/orders/multipleOrders.js b/src/pages/nearle/orders/multipleOrders.js index b32521c..fe1f44c 100644 --- a/src/pages/nearle/orders/multipleOrders.js +++ b/src/pages/nearle/orders/multipleOrders.js @@ -13,6 +13,7 @@ import { Grid } from '@astryxdesign/core/Grid'; import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -27,13 +28,28 @@ import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout'; import { EmptyState } from '@astryxdesign/core/EmptyState'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; -import { MdOutlineCloudUpload, MdDeleteOutline } from 'react-icons/md'; +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so +// sorting/resizing behave identically across the console. No pagination +// feature registered here deliberately — see the comment above dropColumns. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature +} from '@tanstack/react-table'; + +import { MdOutlineCloudUpload, MdDeleteOutline, MdArrowUpward, MdArrowDownward, MdUnfoldMore } from 'react-icons/md'; import { FaUsers, FaPaperPlane, FaRoute, FaMoneyBillWave, FaBoxes, FaReceipt, FaTruck } from 'react-icons/fa'; import { FaLocationDot } from 'react-icons/fa6'; import Loader from 'components/Loader'; import PageHeader from 'components/nearle_components/PageHeader'; import { PageShell } from 'components/nearle_components/PageLayout'; +import { DT } from 'themes/dt/tokens'; import { TableScroll } from 'themes/dt/primitives'; import './OrdersRedesign.css'; @@ -110,6 +126,100 @@ const MetricTile = ({ icon: Icon, label, value, accent, active }) => ( ); +// 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 }) => ( + + {children} + +); + +// 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: () => #, + cell: ({ table: t, row }) => t.getRowModel().rows.indexOf(row) + 1 + }, + { + id: 'name', + accessorKey: 'firstname', + size: 170, + minSize: 130, + meta: { label: 'Name' }, + header: () => Name, + cell: ({ getValue }) => ( + + {getValue() || '—'} + + ) + }, + { + id: 'contact', + accessorKey: 'contactno', + size: 140, + minSize: 110, + meta: { label: 'Contact' }, + header: () => Contact, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'address', + accessorKey: 'address', + size: 260, + minSize: 180, + meta: { label: 'Address' }, + header: () => Address, + cell: ({ getValue }) => ( + + + {getValue() || '—'} + + + ) + }, + { + id: 'qty', + accessorKey: 'quantity', + size: 90, + minSize: 70, + meta: { label: 'Qty', align: 'center' }, + header: () => Qty, + cell: ({ getValue }) => getValue() ?? '—' + }, + { + id: 'cash', + accessorKey: 'collectionamt', + size: 120, + minSize: 100, + meta: { label: 'Cash', align: 'center' }, + header: () => Cash, + cell: ({ getValue }) => { + const v = getValue(); + return v != null ? `₹${Number(v).toFixed(2)}` : '—'; + } + } +]; + const SourceTile = ({ icon: Icon, title, sub, accent, disabled, onClick }) => (
{ const [users, setUsers] = useState([]); const [fileName, setFileName] = useState(''); + // Table sorting/resizing state — mirrors orders.js's/riders.js's slices. + // Two independent table instances (Drop List, File Preview), each with its + // own state. + const [dropSorting, setDropSorting] = useState([]); + const [dropColumnSizing, setDropColumnSizing] = useState({}); + const [previewSorting, setPreviewSorting] = useState([]); + const [previewColumnSizing, setPreviewColumnSizing] = useState({}); + const toastCacheRef = useRef({}); // ============================== toast ============================== @@ -749,6 +867,179 @@ const MultipleOrders = () => { } }; + // Drop List's column model — deliberately NOT wrapped in useMemo: its + // Address/Qty/Cash cells close over uploadType, pickCust and several + // setDropCust-based handlers that change often, and every one of them is + // already recreated fresh each render in this file (none are themselves + // memoized) — memoizing here would need an unmanageable dependency list + // for no benefit, matching the same call made on Tenants.js's Actions + // column. Sorting/resizing are display-only — this table never paginates, + // since the operator has to review and complete EVERY row's address + // before Dispatch is enabled (an incomplete row blocks submit with a + // toast), and hiding some of those rows behind a page-2 would work + // against that. Submission (`createorders`) always reads the full + // `dropCust` array regardless of what's sorted/visible. + const dropColumns = [ + { + id: 'serial', + size: 40, + minSize: 34, + maxSize: 48, + enableSorting: false, + enableResizing: false, + meta: { label: '#' }, + header: () => #, + cell: ({ table: t, row }) => t.getRowModel().rows.indexOf(row) + 1 + }, + { + id: 'customer', + accessorKey: 'firstname', + size: 160, + minSize: 120, + meta: { label: 'Customer' }, + header: () => Customer, + cell: ({ getValue }) => ( + + {getValue()} + + ) + }, + { + id: 'address', + size: 260, + minSize: 200, + enableSorting: false, + meta: { label: 'Address' }, + header: () => Address, + cell: ({ row }) => { + const customer = row.original; + return customer.latitude == null || customer.longitude == null ? ( + 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} + /> + ) : ( + + + {customer.address} + + + ); + } + }, + { + id: 'qty', + size: 110, + minSize: 90, + enableSorting: false, + meta: { label: 'Qty', align: 'center' }, + header: () => Qty, + cell: ({ row }) => { + const customer = row.original; + return uploadType === 0 ? ( + customer.quantity + ) : ( + handleQuantityChange(customer._rowkey, v ?? 0)} + size="sm" + /> + ); + } + }, + { + id: 'cash', + size: 130, + minSize: 100, + enableSorting: false, + meta: { label: 'Cash', align: 'center' }, + header: () => Cash, + cell: ({ row }) => { + const customer = row.original; + return uploadType === 0 ? ( + `₹${Number(customer.collectionamt || 0).toFixed(2)}` + ) : ( + handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)} + size="sm" + /> + ); + } + }, + { + id: 'km', + accessorKey: 'distance', + size: 90, + minSize: 70, + meta: { label: 'Km' }, + header: () => Km, + cell: ({ getValue }) => getValue() || '—' + }, + { + id: 'charge', + accessorFn: (row) => Number(row.totalcharge) || 0, + size: 120, + minSize: 100, + meta: { label: 'Charge', align: 'right' }, + header: () => Charge, + cell: ({ getValue }) => ₹{getValue().toFixed(2)} + }, + { + id: 'action', + size: 56, + minSize: 48, + maxSize: 64, + enableSorting: false, + enableResizing: false, + meta: { label: '' }, + header: () => null, + cell: ({ row }) => { + const customer = row.original; + return ( + } + onClick={() => handleCheckboxChange1(customer)} + /> + ); + } + } + ]; + + const dropTable = useTable({ + features: MULTI_ORDERS_TABLE_FEATURES, + data: dropCust, + columns: dropColumns, + getRowId: (row, index) => row._rowkey ?? `row-${index}`, + columnResizeMode: 'onChange', + state: { sorting: dropSorting, columnSizing: dropColumnSizing }, + onSortingChange: setDropSorting, + onColumnSizingChange: setDropColumnSizing + }); + + const previewTable = useTable({ + features: MULTI_ORDERS_TABLE_FEATURES, + data: users, + columns: previewColumns, + getRowId: (row, index) => row._rowkey ?? `row-${index}`, + columnResizeMode: 'onChange', + state: { sorting: previewSorting, columnSizing: previewColumnSizing }, + onSortingChange: setPreviewSorting, + onColumnSizingChange: setPreviewColumnSizing + }); + return ( <> {(loading || btnLoading) && } @@ -1043,91 +1334,116 @@ const MultipleOrders = () => {
{previewMode === 'drops' && ( - - + +
- - # - Customer - Address - Qty - Cash - Km - Charge - - - - - {dropCust.map((customer, index) => ( - - {index + 1} - - - {customer.firstname} - - - - {customer.latitude == null || customer.longitude == null ? ( - - 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} - /> - ) : ( - - - {customer.address} - - - )} - - - {uploadType === 0 ? ( - customer.quantity - ) : ( - handleQuantityChange(customer._rowkey, v ?? 0)} - size="sm" - /> - )} - - - {uploadType === 0 ? ( - `₹${Number(customer.collectionamt || 0).toFixed(2)}` - ) : ( - handleCollectionAmtChange(customer._rowkey, v && v > 0 ? v : 0)} - size="sm" - /> - )} - - {customer.distance || '—'} - - ₹{Number(customer?.totalcharge || 0).toFixed(2)} - - - } - onClick={() => handleCheckboxChange1(customer)} - /> - + {dropTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
+ +
- - # - Name - Contact - Address - Qty - Cash - + {previewTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
- - - # - Map - Client - Pickup - Drop - Status / Rider - Assigned - Accepted - Arrived - Picked - Active - Delivered - Cancelled - Notes - KMS - Charges - - - - - {fetchDeliveriesIsLoading ? ( - - ) : rows?.length == 0 ? ( - - - } - /> - - - ) : ( - rows?.map((row, index) => { - const isDelivered = row.orderstatus === 'delivered'; - const cancelled = row.orderstatus === 'cancelled'; - return ( - - - - {String(page * rowsPerPage + index + 1).padStart(2, '0')} - - - - - } - isDisabled={!isDelivered} - onClick={() => { - if (isDelivered) { - getdeliverylogs(row.deliveryid); - setMapTenant(row); - } + + + {/* 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.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + - - - - - - - - - - {row.tenantname} - - - - #{row.orderid} - - - - {parseDoormileTimestamp(row.deliverydate).format('DD/MM/YYYY · hh:mm A')} - - - - - - - - - {row.pickupcustomer || '—'} - - - {row.pickupcontactno} - - - - {row.pickupsuburb || (row.Pickupaddress ? row.Pickupaddress.slice(0, 22) + '…' : '')} - - - {row.applocation && } label={row.applocation} />} - - - - - - - {row.deliverycustomer || '—'} - - - {row.deliverycontactno} - - - - {row.deliverysuburb || (row.deliveryaddress ? row.deliveryaddress.slice(0, 22) + '…' : '')} - - - - - - - - - {row.ridername && ( - - - - - {row.ridername} - - - - )} - - - - - - - - - - - - - - {/* 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. */} - - - - - - - - - - - - - - {row.ordernotes ? ( - - - - {row.ordernotes} - + > + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} - ) : ( - - — - - )} - - - - } - label={cancelled || row.kms == '' ? '0.00 km' : `${formatKm(row.kms)} km`} - tooltip="KMS" - /> - } - label={`${formatKm(row.cumulativekms)} km`} - tooltip="Actual KMS" - /> - } - label={`${formatKm(row.previouskms || (cancelled ? 0 : row.kms))} km`} - tooltip="Rider KMS" - /> - - + {header.column.getCanResize() && ( +
-
+ + {fetchDeliveriesIsLoading ? ( + + ) : rows?.length == 0 ? ( + + + } + /> + + + ) : ( + table.getRowModel().rows.map((tableRow) => { + return ( + + {tableRow.getAllCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ); + }) + )} + + + + )} - - {rows?.length !== 0 && ( + {/* Pagination replaces the old scroll-to-reveal sentinel — the + unconditional drain effect above already pulls every matching + order into `allRows` regardless of scroll position, so this + was always paginating an in-memory array, just without any UI + for it. */} + {rows?.length > 0 && ( <> - - {isFetchingNextPage || hasNextPage ? ( - - ) : ( - - No more orders - - )} - + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + rows.length + )} of ${rows.length} order${rows.length === 1 ? '' : 's'}`} + + 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" + /> + )} diff --git a/src/pages/nearle/reports/ordersSummary.js b/src/pages/nearle/reports/ordersSummary.js index c5ff9b5..7ca6d35 100644 --- a/src/pages/nearle/reports/ordersSummary.js +++ b/src/pages/nearle/reports/ordersSummary.js @@ -19,12 +19,34 @@ import { MdOutlineLocalShipping, MdOutlinePendingActions, MdOutlineCheckCircle, - MdOutlineCurrencyRupee + MdOutlineCurrencyRupee, + MdArrowUpward, + MdArrowDownward, + MdUnfoldMore } from 'react-icons/md'; var utc = require('dayjs/plugin/utc'); dayjs.extend(utc); +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use. This +// table's two-row grouped header (Orders/Deliveries each spanning 3 leaf +// columns) uses TanStack's native column-grouping (`columns: [...]` nested +// under a parent column) — `header.rowSpan`/`header.colSpan` are computed +// automatically from the group depth, so the header render loop below skips +// any header reporting `rowSpan === 0` (its cell was already merged into a +// taller cell above it) and renders every other header with its own +// rowSpan/colSpan, per table-core's documented placeholder-chain contract. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; + import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; @@ -36,6 +58,7 @@ import { Badge } from '@astryxdesign/core/Badge'; import { Divider } from '@astryxdesign/core/Divider'; import { Selector } from '@astryxdesign/core/Selector'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Pagination } from '@astryxdesign/core/Pagination'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { getreportlocationsummary, getreportsummary, gettenantlocations, getTenants } from 'pages/api/api'; @@ -124,6 +147,31 @@ function formatNumberToRupees(value) { }).format(Number(value) || 0); } +// TanStack Table v9 registers features explicitly. Sorting + pagination only +// here — deliberately NO columnSizingFeature/columnResizingFeature: this +// table's 2-row grouped header (Orders/Deliveries each colSpan={3}) makes a +// per-column resize handle ambiguous (which row does the handle belong to? +// what does resizing a group header even mean?), a materially fiddlier UX +// problem than the other converted pages. Sorting/pagination carry the same +// value without that complication. +const ORDERS_SUMMARY_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's/riders.js's/Tenants.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + // ============================================================================ // Orders Summary // ============================================================================ @@ -144,6 +192,9 @@ export default function OrdersReport() { const [searchword, setSearchword] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); const [, setPage] = useState(0); + // Table sorting/pagination state — mirrors orders.js's/riders.js's slices. + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); // Clear nested filters when scope widens. useEffect(() => { @@ -360,6 +411,242 @@ export default function OrdersReport() { ); + // 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: () => #, + cell: ({ table: t, row }) => ( + + {String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')} + + ) + }, + { + id: 'group', + accessorFn: (row) => (isLocationGroup ? row.locationname : row.tenantname) || '—', + size: 220, + minSize: 180, + meta: { label: isLocationGroup ? 'Location' : 'Tenant' }, + header: () => {isLocationGroup ? 'Location' : 'Tenant'}, + cell: ({ getValue, row }) => ( + + + {isLocationGroup ? : } + + + + {getValue()} + + + ID #{isLocationGroup ? row.original.locationid : row.original.tenantid} + + + + ) + }, + { + id: 'all', + accessorKey: 'totalorders', + size: 100, + minSize: 80, + meta: { label: 'All', align: 'center' }, + header: () => All, + cell: ({ getValue }) => } /> + }, + { + id: 'ordersGroup', + header: () => Orders, + 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: () => Pending, + cell: ({ getValue }) => } /> + }, + { + id: 'orderscancelled', + accessorKey: 'orderscancelled', + size: 110, + minSize: 90, + meta: { label: 'Orders · Cancelled', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#ef4444' }, + header: () => Cancelled, + cell: ({ getValue }) => } /> + }, + { + id: 'orderscompleted', + accessorKey: 'orderscompleted', + size: 110, + minSize: 90, + meta: { label: 'Orders · Completed', align: 'center', headerBg: ORDERS_GROUP_BG, headerColor: '#10b981' }, + header: () => Completed, + cell: ({ getValue }) => } /> + } + ] + }, + { + id: 'deliveriesGroup', + header: () => Deliveries, + 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: () => Pending, + cell: ({ getValue }) => } /> + }, + { + id: 'deliveriescancelled', + accessorKey: 'deliveriescancelled', + size: 110, + minSize: 90, + meta: { label: 'Deliveries · Cancelled', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#ef4444' }, + header: () => Cancelled, + cell: ({ getValue }) => } /> + }, + { + id: 'deliveriescompleted', + accessorKey: 'deliveriescompleted', + size: 110, + minSize: 90, + meta: { label: 'Deliveries · Completed', align: 'center', headerBg: DELIVERIES_GROUP_BG, headerColor: '#10b981' }, + header: () => Completed, + cell: ({ getValue }) => } /> + } + ] + }, + { + id: 'collection', + accessorKey: 'collectionamt', + size: 130, + minSize: 110, + meta: { label: 'Collection', align: 'center' }, + header: () => Collection, + cell: ({ getValue }) => ( + } + 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: () => KMS / Actual, + cell: ({ row }) => ( + + } + label={`${Number(row.original.kms || 0).toFixed(2)} km`} + tooltip="KMS" + /> + } + label={`${Number(row.original.cumulativekms || 0).toFixed(2)} km`} + tooltip="Actual KMS" + /> + + ) + }, + { + id: 'amount', + accessorFn: (row) => Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0), + size: 140, + minSize: 120, + meta: { label: 'Amount', align: 'center' }, + header: () => Amount, + cell: ({ getValue }) => ( + } + label={formatNumberToRupees(getValue()).replace('₹', '').trim()} + tooltip="Total Amount" + minWidth={100} + /> + ) + }, + { + id: 'action', + size: 90, + minSize: 70, + enableSorting: false, + meta: { label: 'Action' }, + header: () => Action, + cell: ({ row }) => { + const r = row.original; + const rowKey = isLocationGroup ? r.locationname : r.tenantname; + const isOpen = openRow === rowKey; + return ( + + : } + onClick={() => toggleBreakdown(r, rowKey)} + /> + + ); + } + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [isLocationGroup, openRow, pagination.pageIndex, pagination.pageSize] + ); + + const table = useTable({ + features: ORDERS_SUMMARY_TABLE_FEATURES, + // filteredRows is already useMemo'd above (stable reference) — required + // for pagination clicks to stick, see riders.js's fix for the same bug. + data: filteredRows, + columns, + getRowId: (row, index) => String((isLocationGroup ? row.locationname : row.tenantname) ?? `row-${index}`), + state: { sorting, pagination }, + onSortingChange: setSorting, + onPaginationChange: setPagination + }); + + // Leaf-column count (not top-level `columns.length`, which mixes 7 leaves + // with 2 group parents) — this is what loading/empty-state colSpans need + // to actually span every rendered data column. + const colCount = table.getAllLeafColumns().length; + return ( <> {(loading || isLoadingReports) && } @@ -400,9 +687,9 @@ export default function OrdersReport() { not a lifecycle list), so the toolbar just holds every filter including search — same shape as hubs.js/vehicles.js when there's nothing to pair search with. The two-row grouped Orders/Deliveries - header below (rowSpan/colSpan) stays a hand-rolled table rather - than a TanStack Table v9 column-group conversion — a materially - larger, riskier change than this pass's chrome scope. */} + header is a TanStack Table v9 column-group (see the `columns` + useMemo above) — table-core computes rowSpan/colSpan from the + group structure, the render loop below just follows it. */} @@ -486,7 +773,11 @@ export default function OrdersReport() { {!isLoadingReports && filteredRows.length > 0 && ( - {filteredRows.map((row, index) => { + {/* Same paginated + sorted row model the desktop table + reads, so mobile and desktop never disagree on which + page is showing for the same filters. */} + {table.getRowModel().rows.map((tableRow) => { + const row = tableRow.original; const rowKey = isLocationGroup ? row.locationname : row.tenantname; const isOpen = openRow === rowKey; const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0); @@ -494,7 +785,7 @@ export default function OrdersReport() { return ( ) : ( - +
- - # - {isLocationGroup ? 'Location' : 'Tenant'} - - All - - - Orders - - - Deliveries - - - Collection - - - KMS / Actual - - - Amount - - Action - - - - Pending - - - Cancelled - - - Completed - - - Pending - - - Cancelled - - - Completed - - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + // Placeholder chain: a leaf column shallower than the + // table's max depth gets padded with placeholders; + // every header covering the same visual cell after + // the first reports rowSpan 0 and must be skipped — + // rendering it would draw an extra empty
. + if (header.rowSpan === 0) return null; + const meta = header.column.columnDef.meta; + const align = meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + {header.isPlaceholder ? null : ( + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + )} + + ); + })} + + ))} - {isLoadingReports && } + {isLoadingReports && } {!isLoadingReports && filteredRows.length === 0 && ( - + )} - {filteredRows.map((row, index) => { + {table.getRowModel().rows.map((tableRow) => { + const row = tableRow.original; const rowKey = isLocationGroup ? row.locationname : row.tenantname; const isOpen = openRow === rowKey; - const amount = Math.max(Number(row.charges) || 0, Number(row.deliveryamt) || 0); return ( - + - - - {String(index + 1).padStart(2, '0')} - - - - - - {isLocationGroup ? : } - - - - {(isLocationGroup ? row.locationname : row.tenantname) || '—'} - - - ID #{isLocationGroup ? row.locationid : row.tenantid} - - - - - - - } /> - - - } /> - - - } /> - - - } /> - - - } /> - - - } /> - - - } /> - - - - } - label={Number(row.collectionamt || 0).toFixed(2)} - tooltip="Collection Amount" - minWidth={90} - /> - - - - - } - label={`${Number(row.kms || 0).toFixed(2)} km`} - tooltip="KMS" - /> - } - label={`${Number(row.cumulativekms || 0).toFixed(2)} km`} - tooltip="Actual KMS" - /> - - - - - } - label={formatNumberToRupees(amount).replace('₹', '').trim()} - tooltip="Total Amount" - minWidth={100} - /> - - - - - : } - onClick={() => toggleBreakdown(row, rowKey)} - /> - - + {tableRow.getAllCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} {isOpen && ( - +
@@ -779,8 +1002,31 @@ export default function OrdersReport() { )} + {/* 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 && ( <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredRows.length + )} of ${filteredRows.length} ${isLocationGroup ? 'location' : 'tenant'}${filteredRows.length === 1 ? '' : 's'}`} + + 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" + /> + diff --git a/src/pages/nearle/reports/ridersSummary.js b/src/pages/nearle/reports/ridersSummary.js index 904bb05..3192f1b 100644 --- a/src/pages/nearle/reports/ridersSummary.js +++ b/src/pages/nearle/reports/ridersSummary.js @@ -1,4 +1,4 @@ -import React, { useState, useMemo } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import dayjs from 'dayjs'; import { @@ -17,15 +17,34 @@ import { MdPerson, MdOutlineLocalShipping, MdOutlineCheckCircle, - MdOutlineCurrencyRupee + MdOutlineCurrencyRupee, + MdArrowUpward, + MdArrowDownward, + MdUnfoldMore } from 'react-icons/md'; var utc = require('dayjs/plugin/utc'); dayjs.extend(utc); +// TanStack Table v9 — same engine orders.js/riders.js/Tenants.js use, so +// sorting/resizing/pagination behave identically across the console. +import { + useTable, + flexRender, + tableFeatures, + rowSortingFeature, + createSortedRowModel, + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + createPaginatedRowModel +} from '@tanstack/react-table'; + import { HStack } from '@astryxdesign/core/HStack'; import { VStack } from '@astryxdesign/core/VStack'; import { Card } from '@astryxdesign/core/Card'; +import { Center } from '@astryxdesign/core/Center'; import { Text } from '@astryxdesign/core/Text'; import { Button } from '@astryxdesign/core/Button'; import { IconButton } from '@astryxdesign/core/IconButton'; @@ -33,6 +52,7 @@ import { Tooltip } from '@astryxdesign/core/Tooltip'; import { Divider } from '@astryxdesign/core/Divider'; import { Dialog } from '@astryxdesign/core/Dialog'; import { EmptyState } from '@astryxdesign/core/EmptyState'; +import { Pagination } from '@astryxdesign/core/Pagination'; import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table'; import { fetchRidersSummary } from 'pages/api/api'; @@ -121,6 +141,29 @@ function formatNumberToRupees(value) { }).format(Number(value) || 0); } +// TanStack Table v9 registers features explicitly — only sorting/resizing/ +// pagination ship here (no row-selection or column-visibility, since this +// table has no bulk-select or column-hide flow). +const RIDERS_SUMMARY_TABLE_FEATURES = tableFeatures({ + rowSortingFeature, + sortedRowModel: createSortedRowModel(), + sortFns, + columnSizingFeature, + columnResizingFeature, + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel() +}); + +const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; + +// Uppercase micro-label for the table's column headings — same convention +// orders.js's/riders.js's/Tenants.js's tables use. +const SectionLabel = ({ children }) => ( + + {children} + +); + // ==============================|| Riders Summary ||============================== // export default function RidersSummary() { @@ -141,6 +184,11 @@ export default function RidersSummary() { const [routeLoading, setRouteLoading] = useState(false); const [searchword, setSearchword] = useState(''); const [debouncedSearch, setDebouncedSearch] = useState(''); + // Table sorting/resizing/pagination state — mirrors orders.js's/riders.js's + // slices. + const [sorting, setSorting] = useState([]); + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 }); + const [columnSizing, setColumnSizing] = useState({}); // ==============================|| fetchRidersSummary ||============================== // const { isLoading: isLoadingReports, data: rows } = useQuery({ @@ -162,6 +210,12 @@ export default function RidersSummary() { return rows.filter((r) => [r.displayname, String(r.userid)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q))); }, [rows, debouncedSearch]); + // A zone/date/search change re-scopes the list, so page 1 is the only page + // that still means anything — same reset orders.js/riders.js do. + useEffect(() => { + setPagination((p) => ({ ...p, pageIndex: 0 })); + }, [appId, startdate, enddate, debouncedSearch]); + // Aggregate KPIs from the loaded summary. const stats = useMemo(() => { if (!rows || rows.length === 0) return { riders: 0, orders: 0, delivered: 0, amount: 0 }; @@ -287,6 +341,169 @@ export default function RidersSummary() { ); + // 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: () => #, + cell: ({ table: t, row }) => ( + + {String(pagination.pageIndex * pagination.pageSize + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')} + + ) + }, + { + id: 'rider', + accessorFn: (row) => row.displayname || '—', + size: 220, + minSize: 180, + meta: { label: 'Rider' }, + header: () => Rider, + cell: ({ getValue, row }) => ( + + + + + + + {getValue()} + + + ID #{row.original.userid} + + + + ) + }, + { + id: 'orders', + accessorKey: 'assigned', + size: 110, + minSize: 90, + meta: { label: 'Orders' }, + header: () => Orders, + cell: ({ getValue }) => } /> + }, + { + id: 'accepted', + accessorKey: 'accepted', + size: 110, + minSize: 90, + meta: { label: 'Accepted' }, + header: () => Accepted, + cell: ({ getValue }) => } /> + }, + { + id: 'cancelled', + accessorKey: 'cancelled', + size: 110, + minSize: 90, + meta: { label: 'Cancelled' }, + header: () => Cancelled, + cell: ({ getValue }) => } /> + }, + { + id: 'delivered', + accessorKey: 'delivered', + size: 110, + minSize: 90, + meta: { label: 'Delivered' }, + header: () => Delivered, + cell: ({ getValue }) => } /> + }, + { + id: 'kms', + accessorKey: 'riderkms', + size: 120, + minSize: 100, + meta: { label: 'Kms' }, + header: () => Kms, + cell: ({ getValue }) => ( + } 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: () => Amount, + cell: ({ getValue }) => ( + } + label={formatNumberToRupees(getValue()).replace('₹', '').trim()} + tooltip="Total Amount" + minWidth={100} + /> + ) + }, + { + id: 'action', + size: 110, + minSize: 90, + enableSorting: false, + enableResizing: false, + meta: { label: 'Action' }, + header: () => Action, + cell: ({ row }) => { + const r = row.original; + const isOpen = openRow === r.userid; + return ( + + } + onClick={() => openRoutePreview(r)} + /> + : } + onClick={() => toggleBreakdown(r)} + /> + + ); + } + } + ], + // eslint-disable-next-line react-hooks/exhaustive-deps + [pagination.pageIndex, pagination.pageSize, openRow] + ); + + const table = useTable({ + features: RIDERS_SUMMARY_TABLE_FEATURES, + // filteredRows is already useMemo'd above (stable reference) — required + // for pagination clicks to stick, see riders.js's fix for the same bug. + data: filteredRows, + columns, + getRowId: (row, index) => String(row.userid ?? `row-${index}`), + columnResizeMode: 'onChange', + state: { sorting, pagination, columnSizing }, + onSortingChange: setSorting, + onPaginationChange: setPagination, + onColumnSizingChange: setColumnSizing + }); + + const colCount = columns.length; + return ( <> {(isLoadingReports || loading) && } @@ -350,13 +567,17 @@ export default function RidersSummary() { icon={} /> ) : ( - filteredRows.map((row, index) => { + // Same paginated + sorted row model the desktop table reads, + // so mobile and desktop never disagree on what page is + // showing for the same filters. + table.getRowModel().rows.map((tableRow, index) => { + const row = tableRow.original; const isOpen = openRow === row.userid; const amount = Number(row.ridercharges) || 0; const riderName = row?.displayname || '—'; return ( - #{String(index + 1).padStart(2, '0')} · ID #{row.userid} + #{String(pagination.pageIndex * pagination.pageSize + index + 1).padStart(2, '0')} · ID #{row.userid} @@ -434,27 +655,108 @@ export default function RidersSummary() { )} ) : ( - - + + {/* 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. */} +
- - # - Rider - Orders - Accepted - Cancelled - Delivered - KMS - Amount - Action - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
+ + {/* 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. */} +
- - # - ID - Rider - - Substitute Rider - Status - + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const align = header.column.columnDef.meta?.align; + const canSort = header.column.getCanSort(); + const sorted = header.column.getIsSorted(); + return ( + + { + 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' ? ( + + ) : sorted === 'desc' ? ( + + ) : ( + + ))} + + + {header.column.getCanResize() && ( +
)} + + {/* 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 && ( + <> + + + + {`${pagination.pageIndex * pagination.pageSize + 1}–${Math.min( + (pagination.pageIndex + 1) * pagination.pageSize, + filteredRows.length + )} of ${filteredRows.length} rider${filteredRows.length === 1 ? '' : 's'}`} + + 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" + /> + + + )} + {hasAssignments && (