Files
Doormilexpress_console/src/pages/nearle/clients/Tenants.js

1696 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useRef, useMemo, Fragment } from 'react';
import MainCard from 'components/MainCard';
import Loader from 'components/Loader';
import dayjs from 'dayjs';
import { enqueueSnackbar } from 'notistack';
import AddressAutocomplete from 'components/nearle_components/AddressAutocomplete';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { FaRegCheckCircle } from 'react-icons/fa';
import { TbBrandDatabricks, TbWorldLongitude, TbWorldLatitude } from 'react-icons/tb';
import { GiMoneyStack, GiModernCity } from 'react-icons/gi';
import { FiUser, FiPhoneCall } from 'react-icons/fi';
import {
MdNumbers,
MdGroups,
MdCheckCircle,
MdHourglassEmpty,
MdCancel,
MdOutlineCheckCircle,
MdOutlinePendingActions,
MdOutlineCancel,
MdPersonPin,
MdPlace,
MdAdd,
MdEdit,
MdDeleteOutline,
MdPeopleAlt,
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';
import { Badge } from '@astryxdesign/core/Badge';
import { Divider } from '@astryxdesign/core/Divider';
import { TextInput } from '@astryxdesign/core/TextInput';
import { NumberInput } from '@astryxdesign/core/NumberInput';
import { Selector } from '@astryxdesign/core/Selector';
import { 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';
import { Table, TableHeader, TableBody, TableRow, TableCell, TableHeaderCell } from '@astryxdesign/core/Table';
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import TableLoader from 'components/nearle_components/TableLoader';
import { PageShell, KpiGrid, DataCard } from 'components/nearle_components/PageLayout';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getalltenants } from 'pages/api/api';
import {
getAdminPricing,
getDoormilePricing,
createAdminPricing,
updateAdminTenant,
getTenantLocations,
createTenantLocation,
updateTenantLocation,
getTenantCustomers,
createTenantCustomer,
updateTenantCustomer,
deleteTenantCustomer
} from 'pages/api/doormileApi';
import { useIsNarrowViewport } from 'hooks/useIsNarrowViewport';
import { DT } from 'themes/dt/tokens';
import { TableScroll, AccentAvatar } from 'themes/dt/primitives';
const BRAND = DT.brand;
// Status palette — drives the pill tabs and per-row badges. Tenant status is
// a business-entity state (active/pending approval/inactive), a different
// domain from the order-lifecycle registry in themes/dt/status.js, so it
// stays local here the same way riders.js's rider-presence STATUS_META does.
const STATUS_META = {
active: { label: 'Active', color: '#10b981', icon: MdCheckCircle, badge: 'green' },
pending: { label: 'Pending', color: '#f59e0b', icon: MdHourglassEmpty, badge: 'yellow' },
inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel, badge: 'red' }
};
const STATUS_TABS = [
{ status: 'active', countKey: 'active', tabStatus: 'Active' },
{ status: 'pending', countKey: 'pending', tabStatus: 'Pending' },
{ status: 'inactive', countKey: 'inactive', tabStatus: 'InActive' }
];
const KPI_META = [
{ key: 'total', label: 'Total Tenants', color: BRAND, icon: MdGroups, countKey: 'total' },
{ key: 'active', label: 'Active Tenants', color: '#10b981', icon: MdOutlineCheckCircle, countKey: 'active' },
{ key: 'pending', label: 'Pending Approval', color: '#f59e0b', icon: MdOutlinePendingActions, countKey: 'pending' },
{ key: 'inactive', label: 'Inactive Tenants', color: '#ef4444', icon: MdOutlineCancel, countKey: 'inactive' }
];
const VEHICLE_TYPE_OPTIONS = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'].map((v) => ({ value: v, label: v }));
// TanStack Table v9 registers features explicitly — only sorting/resizing/
// pagination ship here (no row-selection or column-visibility, since this
// table has no bulk-select or column-hide flow).
const TENANTS_TABLE_FEATURES = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns,
columnSizingFeature,
columnResizingFeature,
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel()
});
// Uppercase micro-label for the table's column headings — same convention
// orders.js's and riders.js's tables use.
const SectionLabel = ({ children }) => (
<Text type="label" weight="bold" color="secondary" style={{ textTransform: 'uppercase', letterSpacing: 0.4 }}>
{children}
</Text>
);
// ==============================|| Tenant locations (branches/sites) dialog ||============================== //
// Self-contained — deliberately does not touch the existing openRowIndex1/2 +
// Tabs collapse machinery above, which is fragile legacy state. GET/POST/PUT
// /admin/tenants/:id/locations + /admin/tenantlocations/:id per
// jupiter2doormile.md §2.4 — no location UI existed anywhere before this.
const emptyLocationForm = {
locationid: null,
locationname: '',
address: '',
city: '',
state: '',
pincode: '',
isprimary: false,
status: 'active'
};
const TenantLocationsDialog = ({ tenant, open, onClose }) => {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyLocationForm);
const [formOpen, setFormOpen] = useState(false);
const { data: locations = [], isLoading } = useQuery({
queryKey: ['tenant-locations', tenant?.tenantid],
queryFn: () => getTenantLocations(tenant.tenantid),
enabled: !!tenant?.tenantid && open
});
const saveMutation = useMutation({
mutationFn: (payload) =>
payload.locationid ? updateTenantLocation(payload.locationid, payload.data) : createTenantLocation(tenant.tenantid, payload.data),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar(form.locationid ? 'Location updated' : 'Location added', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
queryClient.invalidateQueries({ queryKey: ['tenant-locations', tenant?.tenantid] });
setForm(emptyLocationForm);
setFormOpen(false);
} else {
enqueueSnackbar(res.message || 'Failed to save location', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to save location', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const handleSave = () => {
if (!form.locationname || !form.address) {
enqueueSnackbar('Fill Location Name and Address', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return;
}
const { locationid, ...data } = form;
saveMutation.mutate({ locationid, data });
};
const openEdit = (loc) => {
setForm({
locationid: loc.locationid,
locationname: loc.locationname || '',
address: loc.address || '',
city: loc.city || '',
state: loc.state || '',
pincode: loc.pincode || '',
isprimary: !!loc.isprimary,
status: loc.status || 'active'
});
setFormOpen(true);
};
return (
<Dialog isOpen={open} onOpenChange={(next) => !next && onClose()} width={560} purpose="form">
<Layout
header={<DialogHeader title={`Locations · ${tenant?.tenantname || ''}`} onOpenChange={(next) => !next && onClose()} />}
content={
<LayoutContent padding={4}>
{isLoading ? (
<Text color="secondary">Loading locations…</Text>
) : locations.length === 0 && !formOpen ? (
<EmptyState title="No locations added yet" icon={<MdPlace size={26} />} />
) : (
<VStack gap={2} padding={0}>
{locations.map((loc) => (
<Card key={loc.locationid} padding={2} elevation="none">
<HStack justify="between" vAlign="start">
<VStack gap={0} padding={0}>
<Text weight="bold">
{loc.locationname} {loc.isprimary && '· Primary'}
</Text>
<Text type="supporting" color="secondary">
{[loc.address, loc.city, loc.state, loc.pincode].filter(Boolean).join(', ')}
</Text>
</VStack>
<IconButton
label="Edit location"
tooltip="Edit location"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEdit(loc)}
/>
</HStack>
</Card>
))}
</VStack>
)}
{formOpen && (
<Grid
columns={{ minWidth: 200, repeat: 'fit' }}
gap={2}
style={{ marginTop: 'var(--spacing-6)', paddingTop: 16, borderTop: `1px solid ${DT.divider}` }}
>
<TextInput label="Location Name" value={form.locationname} onChange={(v) => setForm((f) => ({ ...f, locationname: v }))} />
<TextInput label="Pincode" value={form.pincode} onChange={(v) => setForm((f) => ({ ...f, pincode: v }))} />
<TextInput label="Address" value={form.address} onChange={(v) => setForm((f) => ({ ...f, address: v }))} />
<TextInput label="City" value={form.city} onChange={(v) => setForm((f) => ({ ...f, city: v }))} />
<TextInput label="State" value={form.state} onChange={(v) => setForm((f) => ({ ...f, state: v }))} />
</Grid>
)}
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
{formOpen ? (
<HStack gap={2} justify="end">
<Button
label="Cancel"
variant="secondary"
onClick={() => {
setFormOpen(false);
setForm(emptyLocationForm);
}}
/>
<Button label={form.locationid ? 'Save Changes' : 'Add Location'} variant="primary" onClick={handleSave} />
</HStack>
) : (
<HStack gap={2} justify="end">
<Button label="Close" variant="secondary" onClick={onClose} />
<Button label="Add Location" variant="primary" icon={<MdAdd size={16} />} onClick={() => setFormOpen(true)} />
</HStack>
)}
</LayoutFooter>
}
/>
</Dialog>
);
};
// ==============================|| Tenant customers dialog ||============================== //
// GET /admin/tenantcustomers has no documented tenantid filter (unlike
// locations, which are nested under /admin/tenants/:id/locations) — filtered
// client-side here the same way fetchclientpricelist already filters
// /admin/pricing client-side, since neither endpoint accepts the param.
// createclient.js used to (incorrectly) create tenant customers instead of
// tenants; now that it correctly creates tenants, this dialog is the only
// place left that can create one.
const emptyCustomerForm = { id: null, firstname: '', lastname: '', phone: '', email: '' };
// GET /admin/tenantcustomers doesn't document its id field name either — same
// defensive fallback as customers.js's custId() rather than assuming `id`.
const tenantCustId = (row) => row.id ?? row.tenantcustomerid ?? row.customerid;
const TenantCustomersDialog = ({ tenant, open, onClose }) => {
const queryClient = useQueryClient();
const [form, setForm] = useState(emptyCustomerForm);
const [formOpen, setFormOpen] = useState(false);
const { data: allCustomers = [], isLoading } = useQuery({
queryKey: ['tenant-customers-all'],
queryFn: getTenantCustomers,
enabled: open
});
const customers = (allCustomers || []).filter((c) => !tenant?.tenantid || c.tenantid === tenant.tenantid);
const saveMutation = useMutation({
mutationFn: (payload) => (payload.id ? updateTenantCustomer(payload.id, payload.data) : createTenantCustomer(payload.data)),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar(form.id ? 'Customer updated' : 'Customer added', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
queryClient.invalidateQueries({ queryKey: ['tenant-customers-all'] });
setForm(emptyCustomerForm);
setFormOpen(false);
} else {
enqueueSnackbar(res.message || 'Failed to save customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to save customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const deleteMutation = useMutation({
mutationFn: (id) => deleteTenantCustomer(id),
onSuccess: (res) => {
if (res.success) {
enqueueSnackbar('Customer deleted', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
queryClient.invalidateQueries({ queryKey: ['tenant-customers-all'] });
} else {
enqueueSnackbar(res.message || 'Failed to delete customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
},
onError: (err) => {
enqueueSnackbar(err.response?.data?.message || err.message || 'Failed to delete customer', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
}
});
const handleSave = () => {
if (!form.firstname || !form.phone) {
enqueueSnackbar('Fill Name and Phone', {
variant: 'error',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
return;
}
const { id, ...data } = form;
saveMutation.mutate({ id, data });
};
const openEdit = (row) => {
setForm({
id: tenantCustId(row),
firstname: row.firstname || '',
lastname: row.lastname || '',
// GET/POST /admin/tenantcustomers has a write/read asymmetry confirmed
// live: POST accepts `phone`, but the stored/returned field is
// `contactno`. The form field itself stays named `phone` (matching
// what create/update accept) — only the read source differs.
phone: row.contactno || '',
email: row.email || ''
});
setFormOpen(true);
};
const handleDelete = (row) => {
if (window.confirm(`Delete customer "${row.firstname}"?`)) deleteMutation.mutate(tenantCustId(row));
};
return (
<Dialog isOpen={open} onOpenChange={(next) => !next && onClose()} width={560} purpose="form">
<Layout
header={<DialogHeader title={`Customers · ${tenant?.tenantname || ''}`} onOpenChange={(next) => !next && onClose()} />}
content={
<LayoutContent padding={4}>
{isLoading ? (
<Text color="secondary">Loading customers…</Text>
) : customers.length === 0 && !formOpen ? (
<EmptyState title="No customers yet" icon={<MdPeopleAlt size={26} />} />
) : (
<VStack gap={2} padding={0}>
{customers.map((c) => (
<Card key={tenantCustId(c)} padding={2} elevation="none">
<HStack justify="between" vAlign="start">
<VStack gap={0} padding={0}>
<Text weight="bold">
{c.firstname} {c.lastname}
</Text>
<Text type="supporting" color="secondary">
{[c.contactno, c.email].filter(Boolean).join(' · ')}
</Text>
</VStack>
<HStack gap={1}>
<IconButton
label="Edit customer"
tooltip="Edit customer"
size="sm"
variant="ghost"
icon={<MdEdit size={14} />}
onClick={() => openEdit(c)}
/>
<IconButton
label="Delete customer"
tooltip="Delete customer"
size="sm"
variant="destructive"
icon={<MdDeleteOutline size={14} />}
onClick={() => handleDelete(c)}
/>
</HStack>
</HStack>
</Card>
))}
</VStack>
)}
{formOpen && (
<Grid
columns={{ minWidth: 200, repeat: 'fit' }}
gap={2}
style={{ marginTop: 'var(--spacing-6)', paddingTop: 16, borderTop: `1px solid ${DT.divider}` }}
>
<TextInput label="First Name" value={form.firstname} onChange={(v) => setForm((f) => ({ ...f, firstname: v }))} />
<TextInput label="Last Name" value={form.lastname} onChange={(v) => setForm((f) => ({ ...f, lastname: v }))} />
<TextInput label="Phone" value={form.phone} onChange={(v) => setForm((f) => ({ ...f, phone: v }))} />
<TextInput label="Email" value={form.email} onChange={(v) => setForm((f) => ({ ...f, email: v }))} />
</Grid>
)}
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
{formOpen ? (
<HStack gap={2} justify="end">
<Button
label="Cancel"
variant="secondary"
onClick={() => {
setFormOpen(false);
setForm(emptyCustomerForm);
}}
/>
<Button label={form.id ? 'Save Changes' : 'Add Customer'} variant="primary" onClick={handleSave} />
</HStack>
) : (
<HStack gap={2} justify="end">
<Button label="Close" variant="secondary" onClick={onClose} />
<Button label="Add Customer" variant="primary" icon={<MdAdd size={16} />} onClick={() => setFormOpen(true)} />
</HStack>
)}
</LayoutFooter>
}
/>
</Dialog>
);
};
// A single icon + value + caption row used throughout the tenant detail/edit panels.
const InfoRow = ({ icon: Icon, value, caption }) => (
<HStack gap={2} vAlign="center">
<Icon size={18} color={DT.textSecondary} />
<VStack gap={0} padding={0}>
<Text>{value || '—'}</Text>
<Text type="supporting" color="secondary">
{caption}
</Text>
</VStack>
</HStack>
);
const PricingTable = ({ list }) => (
<TableScroll minWidth={560}>
<Table density="balanced" dividers="rows">
<TableHeader>
<TableRow>
<TableHeaderCell style={{ minWidth: 40, width: 40, maxWidth: 40 }}>#</TableHeaderCell>
<TableHeaderCell>Vehicle</TableHeaderCell>
<TableHeaderCell>Base Price</TableHeaderCell>
<TableHeaderCell>Price/Km</TableHeaderCell>
<TableHeaderCell>Price/Kg</TableHeaderCell>
<TableHeaderCell>Handling</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
</TableRow>
</TableHeader>
<TableBody>
{list?.length === 0 ? (
<TableRow>
<TableCell colSpan={7}>
<EmptyState title="No pricing list" icon={<GiMoneyStack size={24} />} />
</TableCell>
</TableRow>
) : (
list.map((val, i) => (
<TableRow key={val.pricingid || i}>
<TableCell>{i + 1}</TableCell>
<TableCell>{val.vehicletype || '—'}</TableCell>
<TableCell>{val.baseprice ?? '—'}</TableCell>
<TableCell>{val.priceperkm ?? '—'}</TableCell>
<TableCell>{val.priceperkg ?? '—'}</TableCell>
<TableCell>{val.handlingcharges ?? '—'}</TableCell>
<TableCell>{val.status || '—'}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableScroll>
);
// ==============================|| Starts here||============================== //
const Clients1 = () => {
const textFieldRef = useRef(null);
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [rowsPerPage, setRowsPerPage] = useState(10);
const isMobile = useIsNarrowViewport();
const [isloader, setisloader] = useState(false);
const [appId, setAppId] = useState(0);
const [locaName, setLocoName] = useState('');
const [value0, setValue0] = useState(0);
const [value1, setValue1] = useState('0');
const [value2, setValue2] = useState('0');
const [selectedTenid, setSelectedtenid] = useState(null);
const [clientpricelist, setClientpricelist] = useState([]);
const [status, setstatus] = useState('active');
const [selectedCustomer, setSelectedCustomer] = useState({}); // to edit
const [dialogopen, setDialogopen] = useState(false);
const [, setAppPricing] = useState([]);
const [selectedPricing, setSelectedPricing] = useState({});
const [isPrice, setIsprice] = useState(true);
const [city, setCity] = useState('');
const [zipcode, setZipcode] = useState('');
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);
};
const handleChange = (newValue) => {
setValue0(newValue);
setSearchword('');
setPage(0);
};
const [openRowIndex1, setOpenRowIndex1] = useState(null); // Initially no row is open for collapsible section 1
const [openRowIndex2, setOpenRowIndex2] = useState(null); // Initially no row is open for collapsible section 2
const [locationsDialogTenant, setLocationsDialogTenant] = useState(null);
const [customersDialogTenant, setCustomersDialogTenant] = useState(null);
// Handle toggling for collapsible section 1
const handleCollapseToggle1 = (rowIndex) => {
setOpenRowIndex1((prevIndex) => (prevIndex === rowIndex ? null : rowIndex));
};
// Handle toggling for collapsible section 2
const handleCollapseToggle2 = (rowIndex) => {
setOpenRowIndex2((prevIndex) => (prevIndex === rowIndex ? null : rowIndex));
};
useEffect(() => {
setOpenRowIndex1(-1);
setOpenRowIndex2(-1);
}, [value0]);
/* ============================================= || opentoast || ============================================= */
const opentoast = (message, variant, duration) => {
enqueueSnackbar(message, {
variant: variant,
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: duration
});
};
// ==============================|| address autocomplete ||============================== //
const handleAddressPlaceSelected = (place) => {
const lat = place.geometry.location.lat();
const lng = place.geometry.location.lng();
setLatlong({ lat, lng });
setEditClient((prev) => ({
...prev,
address: place.formatted_address,
latitude: lat.toString(),
longitude: lng.toString()
}));
let city1, zipcode1, state1, suburb1;
place.address_components.forEach((component) => {
component.types.forEach((type) => {
switch (type) {
case 'locality':
city1 = component.long_name;
break;
case 'administrative_area_level_1':
state1 = component.long_name;
break;
case 'postal_code':
zipcode1 = component.long_name;
break;
case 'sublocality':
case 'sublocality_level_1':
suburb1 = component.long_name;
break;
}
});
});
setCity(city1 || '');
setState(state1 || '');
setZipcode(zipcode1 || '');
setSuburb(suburb1 || '');
setEditClient((prev) => ({
...prev,
city: city1 || '',
state: state1 || '',
postcode: zipcode1 || '',
suburb: suburb1 || ''
}));
};
useEffect(() => {
selectedCustomer &&
setLatlong({
lat: selectedCustomer.latitude,
lng: selectedCustomer.longitude
});
}, [selectedCustomer]);
/* ============================================= || handleKeyPress (ctrl+k)| ============================================= */
useEffect(() => {
const handleKeyPress = (event) => {
if (event.key === 'k' && (event.metaKey || event.ctrlKey)) {
event.preventDefault();
textFieldRef.current.focus();
}
if (event.key === 'Escape' && document.activeElement === textFieldRef.current) {
// Remove focus from the TextField
textFieldRef.current.blur();
}
};
document.addEventListener('keydown', handleKeyPress);
return () => {
document.removeEventListener('keydown', handleKeyPress);
};
}, []);
/* ============================================= || getalltenants| ============================================= */
const {
data: tenantList,
isLoading: getalltenantsIsLoading,
refetch: getalltenantsRefetch
} = useQuery({
queryKey: ['tenantList', appId, debouncedSearch, status, page, rowsPerPage],
queryFn: getalltenants
});
// No tenant-tab-counts endpoint in the new API (gettenantsummary always
// returns null) — derive the Active/Pending/Inactive counts client-side
// from the full tenant list instead, which getalltenants already fetches.
const tenantStatusCounts = useMemo(() => {
const counts = { active: 0, pending: 0, inactive: 0 };
(tenantList || []).forEach((t) => {
const s = String(t.status || '').toLowerCase();
if (s === 'active') counts.active += 1;
else if (s === 'pending') counts.pending += 1;
else if (s === 'inactive') counts.inactive += 1;
});
return { ...counts, total: (tenantList || []).length };
}, [tenantList]);
// getalltenants() takes no filter args (see api.js) — the status tabs and
// search box only ever changed the query-cache key, never the actual
// request, so every tab showed the exact same unfiltered list with just a
// relabeled status badge. Filter the already-fetched list client-side
// instead, matching the same real `status` field tenantStatusCounts uses.
const filteredTenantList = useMemo(() => {
const activeStatus = STATUS_TABS[value0]?.status;
const q = debouncedSearch.trim().toLowerCase();
return (tenantList || []).filter((t) => {
if (String(t.status || '').toLowerCase() !== activeStatus) return false;
if (!q) return true;
return (
t.tenantname?.toLowerCase().includes(q) || t.primaryemail?.toLowerCase().includes(q) || t.primarycontact?.includes(debouncedSearch)
);
});
}, [tenantList, value0, debouncedSearch]);
/* ============================================= || fetchclientpricelist || ============================================= */
// /admin/pricing has no tenantid query param documented — filter client-side.
const fetchclientpricelist = async () => {
try {
const pricing = await getAdminPricing();
setClientpricelist((pricing || []).filter((p) => p.tenantid === selectedTenid));
} catch (err) {
// swallow
}
};
useEffect(() => {
selectedTenid && fetchclientpricelist();
}, [selectedTenid]);
/* ============================================= || fetchTenanatPricing || ============================================= */
const fetchTenanatPricing = async (id) => {
try {
const pricing = await getAdminPricing();
setTenanatPricing((pricing || []).filter((p) => p.tenantid === id));
} catch (error) {
// swallow
}
};
useEffect(() => {
selectedTenid && fetchTenanatPricing(selectedTenid);
}, [selectedTenid]);
/* ============================================= || tenantupdate || ============================================= */
const tenantupdate = async (tenid) => {
setisloader(true);
let targetId;
let updateData;
if (tenid == -1) {
targetId = selectedTenid;
updateData = { approved: 1 };
} else {
targetId = tenid;
updateData = { status: status === 'active' ? 'InActive' : 'Active' };
}
try {
const res = await updateAdminTenant(targetId, updateData);
if (res.success) {
opentoast(
value0 == 0 ? 'Inactivated Successfully' : value0 == 1 ? 'Approved Successfully' : 'Activate Successfully',
'success',
2000
);
getalltenantsRefetch();
}
} catch (err) {
opentoast(err.message, 'error', 1500);
} finally {
setisloader(false);
}
};
/* ============================================= || getAppPricing || ============================================= */
// /admin/doormile-pricing has no zone query param documented — filter client-side.
const getAppPricing = async (id) => {
try {
const pricing = await getDoormilePricing();
setAppPricing((pricing || []).filter((p) => !id || p.applocationid === id));
} catch (error) {
// swallow
}
};
/* ============================================= || createpricing || ============================================= */
const createpricing = async () => {
setisloader(true);
try {
const res = await createAdminPricing({
applocationid: appId,
tenantid: selectedTenid,
vehicletype: selectedPricing.vehicletype || 'Bike',
baseprice: +selectedPricing.baseprice || 0,
baseweight: +selectedPricing.baseweight || 0,
priceperkm: +selectedPricing.priceperkm || 0,
priceperkg: +selectedPricing.priceperkg || 0,
basedistance: +selectedPricing.basedistance || 0,
handlingcharges: +selectedPricing.handlingcharges || 0,
// Confirmed live: POST /admin/pricing rejects a bare YYYY-MM-DD date
// for effectivefrom ("invalid request body") — needs a full ISO
// datetime. Status also confirmed to need title case ("Active"),
// matching every real pricing row.
effectivefrom: dayjs().toISOString(),
currency: 'INR',
priority: 1,
status: 'Active'
});
if (res.success) {
enqueueSnackbar('Price Created Successfully', {
variant: 'success',
anchorOrigin: { vertical: 'top', horizontal: 'right' },
autoHideDuration: 2000
});
dialogclose();
setSelectedPricing({});
tenantupdate(-1);
fetchclientpricelist();
} else {
opentoast(res.message || 'Failed to save pricing', 'error', 2000);
}
} catch (err) {
opentoast(err.response?.data?.message || err.message || 'Failed to save pricing', 'error', 2000);
}
};
/* ============================================= || updateClient || ============================================= */
const updateClient = async () => {
try {
const updateRes = await updateAdminTenant(selectedTenid, editClient);
if (updateRes.success) {
opentoast(updateRes.message || 'Updated Successfully', 'success', 1500);
} else {
opentoast(updateRes.message || 'Update Failed', 'error', 1500);
}
getalltenantsRefetch();
} catch (err) {
opentoast(err.response?.data?.message || err.message || 'Update Failed', 'error', 1500);
}
};
const activeTabMeta = STATUS_META[STATUS_TABS[value0]?.status] || STATUS_META.active;
// Icon buttons only, kept to 3 max + one overflow menu: status action,
// view details, edit, then "More" (Locations/Customers — secondary,
// low-frequency actions) behind a kebab menu.
//
// Sizing: `size="md"` is Astryx's own 32px button height (confirmed in
// Button's sizeStyles — sm=28px/md=32px/lg=36px) and icon-only buttons are
// always square, so this is a native 32×32px button, not a manually forced
// one. Its border-radius is Astryx's own default --radius-element (8px —
// see the identical note in dispatch/Dispatch.css), so no radius override
// is needed either — both requirements were already the design system's
// own defaults once the size prop was corrected.
//
// Icon family: every icon here is the Material "outline" variant
// (MdOutline*). Mixing MdBlock/MdVisibility/MdEdit/MdClose/MdMoreVert
// (filled) with MdOutlineCheckCircle (outline) — the state before this
// pass — is what actually read as inconsistent, not the icons' size or
// position. MdPlace/MdEdit/MdPeopleAlt stay filled where they're used
// elsewhere in this file (Locations/Customers/Pricing dialogs) — only this
// row switches to the Outline set.
//
// Colour: the status-action slot (Inactivate/Approve/Activate — whichever
// one applies to the active tab) is the one action with real consequence
// for the tenant's account state, so it keeps the `destructive` (red-
// tinted) treatment across all three tab variants for a consistent,
// predictable "this is the serious one" signal. View/Edit/More stay
// `ghost` (transparent, neutral) — `primary` only kicks in on View/Edit
// when their panel is the one currently open, an existing state indicator
// preserved as-is, not a new style.
const renderRowActions = (row, index, expanded, size = 'md') => (
<HStack gap={2} vAlign="center" justify="end" style={{ paddingRight: 'var(--spacing-4)' }}>
{value0 == 0 && (
<IconButton
label="Inactivate"
tooltip="Inactivate"
size={size}
variant="destructive"
icon={<MdOutlineBlock size={17} />}
onClick={() => {
setSelectedCustomer(row);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
setTimeout(() => {
tenantupdate(row.tenantid);
}, 100);
}}
/>
)}
{value0 == 1 && (
<IconButton
label="Approve"
tooltip="Approve"
size={size}
variant="destructive"
icon={<MdOutlineCheckCircle size={17} />}
onClick={() => {
setSelectedCustomer(row);
setDialogopen(true);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
getAppPricing(row.applocationid);
}}
/>
)}
{value0 == 2 && (
<IconButton
label="Activate"
tooltip="Activate"
size={size}
variant="destructive"
icon={<MdOutlineCheckCircle size={17} />}
onClick={() => {
setSelectedCustomer(row);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
setTimeout(() => {
tenantupdate(row.tenantid);
}, 100);
}}
/>
)}
<IconButton
label={expanded ? 'Hide details' : 'View details'}
tooltip={expanded ? 'Hide details' : 'View details'}
size={size}
variant={expanded ? 'primary' : 'ghost'}
icon={expanded ? <MdOutlineVisibilityOff size={17} /> : <MdOutlineVisibility size={17} />}
onClick={() => {
setSelectedCustomer(row);
handleCollapseToggle1(index);
setOpenRowIndex2(-1);
setSelectedtenid(row.tenantid);
}}
/>
{value0 !== 1 && (
<IconButton
label={openRowIndex2 === index ? 'Close' : 'Edit'}
tooltip={openRowIndex2 === index ? 'Close' : 'Edit'}
size={size}
variant={openRowIndex2 === index ? 'primary' : 'ghost'}
icon={openRowIndex2 === index ? <MdOutlineClose size={17} /> : <MdOutlineEdit size={17} />}
onClick={() => {
setSelectedCustomer(row);
handleCollapseToggle2(index);
setOpenRowIndex1(-1);
setSelectedtenid(row.tenantid);
setAppId(row.applocationid);
}}
/>
)}
<DropdownMenu
button={{
label: 'More actions',
tooltip: 'More actions',
variant: 'ghost',
size,
icon: <MdOutlineMoreVert size={17} />,
isIconOnly: true
}}
menuWidth={180}
placement="below"
items={[
{ label: 'Locations', icon: <MdOutlinePlace size={16} />, onClick: () => setLocationsDialogTenant(row) },
{ label: 'Customers', icon: <MdOutlinePeopleAlt size={16} />, onClick: () => setCustomersDialogTenant(row) }
]}
/>
</HStack>
);
const renderDetailsPanel = (row) => (
<MainCard
elevation="none"
title={
<TabList value={value1} onChange={setValue1} size="sm">
<Tab value="0" label="Details" icon={<TbBrandDatabricks size={16} />} />
<Tab value="1" label="Pricing" icon={<GiMoneyStack size={16} />} />
</TabList>
}
>
{value1 == '0' && (
<VStack gap={0} padding={4}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<InfoRow icon={FiUser} value={row.tenantname} caption="Tenant" />
<InfoRow icon={FaRegCheckCircle} value={row.status || '—'} caption="Status" />
<InfoRow icon={FiPhoneCall} value={row.primarycontact} caption="Phone" />
<InfoRow icon={LuMail} value={row.primaryemail} caption="E-Mail" />
<InfoRow
icon={row.requiredeliveryotp ? MdCheckCircle : MdCancel}
value={row.requiredeliveryotp ? 'Yes' : 'No'}
caption="Delivery OTP Required"
/>
</Grid>
</VStack>
)}
{value1 == '1' && (
<VStack gap={0} padding={3}>
<PricingTable list={clientpricelist} />
</VStack>
)}
</MainCard>
);
const renderEditPanel = (row) => (
<MainCard
elevation="none"
title={
<HStack justify="between" vAlign="center" style={{ width: '100%' }}>
<TabList
value={value2}
onChange={(v) => {
setValue2(v);
if (v === '1') getAppPricing(selectedCustomer.applocationid);
}}
size="sm"
>
<Tab value="0" label="Edit Details" icon={<TbBrandDatabricks size={16} />} />
{value0 !== 1 && <Tab value="1" label="Edit Pricing" icon={<GiMoneyStack size={16} />} />}
</TabList>
{value2 == '1' && (
<Button label="Add Pricing" variant="destructive" size="sm" icon={<MdEdit size={14} />} onClick={() => setDialogopen(true)} />
)}
</HStack>
}
>
{value2 == '0' && (
<VStack gap={3} padding={4}>
<Grid columns={{ minWidth: 240, repeat: 'fit' }} gap={3}>
<TextInput
label="Tenant Name"
value={editClient.tenantname ?? selectedCustomer.tenantname ?? ''}
onChange={(v) => setEditClient((f) => ({ ...f, tenantname: v }))}
startIcon={BiUser}
/>
<TextInput
label="Contact Person"
value={editClient.firstname ?? row.firstname ?? ''}
onChange={(v) => setEditClient((f) => ({ ...f, firstname: v }))}
startIcon={FiUser}
/>
<TextInput
label="Contact Number"
value={editClient.primarycontact ?? selectedCustomer.primarycontact ?? ''}
onChange={(v) => setEditClient((f) => ({ ...f, primarycontact: v.slice(0, 10) }))}
startIcon={FiPhoneCall}
/>
<TextInput
label="E-Mail"
value={editClient.primaryemail ?? selectedCustomer.primaryemail ?? ''}
onChange={(v) => setEditClient((f) => ({ ...f, primaryemail: v }))}
startIcon={LuMail}
/>
</Grid>
<AddressAutocomplete
id="address-autocomplete"
label="Address"
value={editClient?.address ?? selectedCustomer.address ?? ''}
onChange={(text) => setEditClient((prev) => ({ ...prev, address: text }))}
onPlaceSelected={handleAddressPlaceSelected}
/>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<TextInput
label="City"
value={city == '' ? selectedCustomer.city || '' : city}
onChange={(v) => setEditClient((f) => ({ ...f, city: v }))}
startIcon={GiModernCity}
isRequired
/>
<TextInput
label="PostCode"
value={zipcode == '' ? selectedCustomer.postcode || '' : zipcode}
onChange={(v) => setEditClient((f) => ({ ...f, postcode: v }))}
startIcon={MdNumbers}
isRequired
/>
</Grid>
<Grid columns={{ minWidth: 200, repeat: 'fit' }} gap={3}>
<TextInput label="Latitude" value={latlong.lat ?? ''} isDisabled startIcon={TbWorldLatitude} />
<TextInput label="Longitude" value={latlong.lng ?? ''} isDisabled startIcon={TbWorldLongitude} />
</Grid>
<HStack justify="end">
<Button label="Update Client" variant="primary" onClick={updateClient} />
</HStack>
</VStack>
)}
{value2 == '1' && (
<VStack gap={0} padding={3}>
<PricingTable list={clientpricelist} />
</VStack>
)}
</MainCard>
);
// Bridges TanStack's { pageIndex, pageSize } pagination shape onto the
// existing page/rowsPerPage state, so every other reader of those two
// values (the tenantList query key, handleChange, the page-size onChange
// below) keeps working unchanged.
const paginationState = { pageIndex: page, pageSize: rowsPerPage };
const handlePaginationChange = (updater) => {
const next = typeof updater === 'function' ? updater(paginationState) : updater;
if (next.pageIndex !== page) setPage(next.pageIndex);
if (next.pageSize !== rowsPerPage) setRowsPerPage(next.pageSize);
};
// The column model — everything a column IS declared once here instead of
// a hardcoded header row + hand-written body row, same shape as orders.js's
// and riders.js's `columns`. Deliberately NOT wrapped in useMemo: the
// Actions column's cell calls `renderRowActions`, which closes over value0/
// openRowIndex2/several setters that change often — memoizing here would
// need an unmanageable dependency list, and recomputing ~6 column defs a
// render is cheap. Row-expand state is keyed by `row.tenantid` (not
// position), so it stays correct once sorting/pagination reorder rows.
const columns = [
{
id: 'serial',
size: 44,
minSize: 36,
maxSize: 52,
enableSorting: false,
enableResizing: false,
meta: { label: '#' },
header: () => <SectionLabel>#</SectionLabel>,
cell: ({ table: t, row }) => (
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{String(page * rowsPerPage + t.getRowModel().rows.indexOf(row) + 1).padStart(2, '0')}
</Text>
)
},
{
id: 'status',
accessorKey: 'status',
size: 140,
minSize: 110,
meta: { label: 'Status' },
header: () => <SectionLabel>Status</SectionLabel>,
cell: ({ getValue }) => {
const meta = STATUS_META[String(getValue() || '').toLowerCase()] || STATUS_META.active;
const Icon = meta.icon;
return <Badge variant={meta.badge} icon={<Icon size={12} />} label={meta.label} />;
}
},
{
id: 'client',
accessorKey: 'tenantname',
size: 260,
minSize: 200,
meta: { label: 'Client' },
header: () => <SectionLabel>Client</SectionLabel>,
cell: ({ getValue, row }) => (
<HStack gap={1.5} vAlign="center">
<AccentAvatar color={BRAND} selected size={32}>
<MdPersonPin size={16} />
</AccentAvatar>
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue()}
</Text>
<Text type="supporting" color="secondary">
ID #{row.original.tenantid}
</Text>
</VStack>
</HStack>
)
},
{
id: 'contact',
accessorKey: 'primarycontact',
size: 220,
minSize: 160,
meta: { label: 'Contact' },
header: () => <SectionLabel>Contact</SectionLabel>,
cell: ({ getValue, row }) => (
<VStack gap={0} padding={0}>
<Text weight="semibold" style={{ whiteSpace: 'nowrap' }}>
{getValue() || '—'}
</Text>
<Text type="supporting" color="secondary">
{row.original.primaryemail || '—'}
</Text>
</VStack>
)
},
{
id: 'deliveryOtp',
accessorFn: (row) => (row.requiredeliveryotp ? 'Yes' : 'No'),
size: 150,
minSize: 130,
meta: { label: 'Delivery OTP', align: 'center' },
header: () => <SectionLabel>Delivery OTP</SectionLabel>,
cell: ({ getValue }) => (
<Text type="supporting" color="secondary">
{getValue()}
</Text>
)
},
{
id: 'actions',
// Up to 4 controls: status action + view details + edit + a "More"
// overflow menu (Locations/Customers) — wide enough that 32px buttons
// with 8px gaps never wrap to a second line.
size: 220,
minSize: 190,
enableSorting: false,
enableResizing: false,
meta: { label: 'Actions', align: 'right' },
header: () => <SectionLabel>Actions</SectionLabel>,
cell: ({ row }) => renderRowActions(row.original, row.original.tenantid, openRowIndex1 === row.original.tenantid)
}
];
const table = useTable({
features: TENANTS_TABLE_FEATURES,
// filteredTenantList is already useMemo'd above (stable reference across
// renders) — required for TanStack's row-model memoization to actually
// hold and for pagination clicks to stick (an unmemoized array here
// reads as "data changed" on every render and auto-resets to page 1,
// the exact bug fixed on riders.js's table).
data: filteredTenantList,
columns,
getRowId: (row, index) => String(row.tenantid ?? `row-${index}`),
columnResizeMode: 'onChange',
state: { sorting, pagination: paginationState, columnSizing },
onSortingChange: setSorting,
onPaginationChange: handlePaginationChange,
onColumnSizingChange: setColumnSizing
});
// Skeleton rows and the empty state span exactly the columns that are
// actually rendered. Read off `columns.length` directly (not
// `table.getVisibleLeafColumns()`): that method only exists when
// columnVisibilityFeature is registered, which this table doesn't need.
const colCount = columns.length;
return (
<>
{(getalltenantsIsLoading || isloader) && <Loader />}
<PageShell>
<PageHeader
title="Tenants"
subtitle={`Live · ${locaName || 'All Zones'}`}
live
action={
<LocationAutocomplete
locaName={locaName}
setAppId={setAppId}
setLocoName={setLocoName}
setPage={setPage}
placeholder="Select Zone"
/>
}
/>
<KpiGrid>
{KPI_META.map((item) => {
const Icon = item.icon;
const value = tenantStatusCounts[item.countKey] ?? 0;
// 'total' has no matching entry in STATUS_TABS (there's no "All"
// tab) — only the three status tiles switch tabs when clicked.
const tabIdx = STATUS_TABS.findIndex((t) => t.countKey === item.countKey);
const clickable = tabIdx !== -1;
return (
<div
key={item.key}
role={clickable ? 'button' : undefined}
tabIndex={clickable ? 0 : undefined}
onClick={clickable ? () => handleChange(tabIdx) : undefined}
onKeyDown={
clickable
? (e) => {
if (e.key === 'Enter' || e.key === ' ') handleChange(tabIdx);
}
: undefined
}
style={{ cursor: clickable ? 'pointer' : 'default', height: '100%' }}
>
<StatCard
title={item.label}
value={value ?? 0}
icon={<Icon size={20} />}
color={item.color}
loading={getalltenantsIsLoading}
/>
</div>
);
})}
</KpiGrid>
<DataCard
tabs={
<HStack justify="between" vAlign="center" gap={2} wrap="wrap">
<TabList
value={String(value0)}
onChange={(v) => {
const idx = Number(v);
setstatus(STATUS_TABS[idx].status);
handleChange(idx);
}}
size="sm"
hasDivider={false}
>
{STATUS_TABS.map((t, idx) => {
const meta = STATUS_META[t.status];
const Icon = meta.icon;
const count = tenantStatusCounts[t.countKey] ?? 0;
return (
<Tab
key={t.status}
value={String(idx)}
label={meta.label}
icon={<Icon size={15} />}
endContent={<Badge variant="neutral" label={count} />}
/>
);
})}
</TabList>
<VStack maxWidth={280} width="100%" padding={0} gap={0}>
<DebounceSearchBar value={searchword} onChange={setSearchword} onDebouncedChange={setDebouncedSearch} />
</VStack>
</HStack>
}
>
{!isMobile && (
<TableScroll minWidth={table.getTotalSize()}>
{/* table-layout: fixed makes the declared column widths
authoritative — same reasoning as orders.js's/riders.js's
tables. Header and body are both rendered FROM the column
model, so they cannot drift apart. */}
<Table
density="balanced"
dividers="rows"
hasHover
verticalAlign="middle"
aria-label="Tenants"
style={{ tableLayout: 'fixed', width: '100%', minWidth: table.getTotalSize() }}
>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} isHeaderRow>
{headerGroup.headers.map((header) => {
const align = header.column.columnDef.meta?.align;
const canSort = header.column.getCanSort();
const sorted = header.column.getIsSorted();
return (
<TableHeaderCell
key={header.id}
scope="col"
aria-sort={sorted === 'asc' ? 'ascending' : sorted === 'desc' ? 'descending' : canSort ? 'none' : undefined}
style={{
width: header.getSize(),
minWidth: header.column.columnDef.minSize,
maxWidth: header.column.columnDef.maxSize || header.getSize(),
textAlign: align,
position: 'relative'
}}
>
<HStack
gap={1}
vAlign="center"
justify={align === 'right' ? 'end' : align === 'center' ? 'center' : 'start'}
role={canSort ? 'button' : undefined}
tabIndex={canSort ? 0 : undefined}
onClick={canSort ? header.column.getToggleSortingHandler() : undefined}
onKeyDown={
canSort
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
header.column.toggleSorting();
}
}
: undefined
}
style={canSort ? { cursor: 'pointer', userSelect: 'none' } : undefined}
>
{flexRender(header.column.columnDef.header, header.getContext())}
{canSort &&
(sorted === 'asc' ? (
<MdArrowUpward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : sorted === 'desc' ? (
<MdArrowDownward size={12} color={DT.textPrimary} style={{ flexShrink: 0 }} />
) : (
<MdUnfoldMore size={12} color={DT.textMuted} style={{ flexShrink: 0, opacity: 0.6 }} />
))}
</HStack>
{header.column.getCanResize() && (
<Center
aria-hidden="true"
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
style={{
position: 'absolute',
top: 0,
right: 0,
height: '100%',
width: 10,
cursor: 'col-resize',
userSelect: 'none',
touchAction: 'none'
}}
>
<Center
style={{
width: 2,
height: '60%',
borderRadius: 'var(--radius-element)',
backgroundColor: header.column.getIsResizing() ? DT.brand : DT.borderSubtle,
transition: 'background-color 120ms ease'
}}
/>
</Center>
)}
</TableHeaderCell>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{getalltenantsIsLoading && <TableLoader rows={5} columns={colCount} />}
{filteredTenantList.length == 0 && !isloader ? (
<TableRow>
<TableCell colSpan={colCount}>
<EmptyState
title="No tenants to show"
description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
icon={<MdGroups size={28} />}
/>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const expanded1 = openRowIndex1 === row.tenantid;
const expanded2 = openRowIndex2 === row.tenantid;
return (
<Fragment key={tableRow.id}>
<TableRow>
{tableRow.getAllCells().map((cell) => (
<TableCell
key={cell.id}
style={{
textAlign: cell.column.columnDef.meta?.align,
maxWidth: cell.column.columnDef.maxSize || cell.column.getSize()
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
{expanded1 && (
<TableRow>
<TableCell colSpan={colCount} style={{ padding: 12 }}>
{renderDetailsPanel(row)}
</TableCell>
</TableRow>
)}
{expanded2 && (
<TableRow>
<TableCell colSpan={colCount} style={{ padding: 12 }}>
{renderEditPanel(row)}
</TableCell>
</TableRow>
)}
</Fragment>
);
})
)}
</TableBody>
</Table>
</TableScroll>
)}
{isMobile && (
<MobileCardList scroll>
{filteredTenantList.length == 0 && !isloader ? (
<EmptyState
title="No tenants to show"
description={`No ${activeTabMeta.label.toLowerCase()} tenants for this filter.`}
icon={<MdGroups size={28} />}
/>
) : (
table.getRowModel().rows.map((tableRow) => {
const row = tableRow.original;
const rowStatusKey = String(row.status || '').toLowerCase();
const rowStatusMeta = STATUS_META[rowStatusKey] || STATUS_META.active;
const RowStatusIcon = rowStatusMeta.icon;
const expanded = openRowIndex1 === row.tenantid;
return (
<MobileCard
key={tableRow.id}
accent={rowStatusMeta.color}
header={
<HStack justify="between" vAlign="start" gap={1}>
<HStack gap={1.5} vAlign="center" minWidth={0}>
<AccentAvatar color={BRAND} selected size={36}>
<MdPersonPin size={18} />
</AccentAvatar>
<VStack gap={0.5} padding={0} minWidth={0}>
<Text weight="bold" maxLines={1}>
{row.tenantname}
</Text>
<Badge variant={rowStatusMeta.badge} icon={<RowStatusIcon size={10} />} label={rowStatusMeta.label} />
</VStack>
</HStack>
{renderRowActions(row, row.tenantid, expanded)}
</HStack>
}
>
<MobileFieldGrid>
<MobileField label="Tenant ID" value={`#${row.tenantid}`} />
<MobileField label="Contact" value={row.primarycontact || '—'} />
<MobileField label="Email" value={row.primaryemail || '—'} full />
<MobileField label="Status" value={row.status || '—'} />
</MobileFieldGrid>
{expanded && (
<div style={{ marginTop: 10, paddingTop: 10, borderTop: `1px solid ${DT.divider}` }}>
<MobileFieldGrid>
<MobileField label="Delivery OTP Required" value={row.requiredeliveryotp ? 'Yes' : 'No'} />
</MobileFieldGrid>
</div>
)}
</MobileCard>
);
})
)}
</MobileCardList>
)}
{/* Page-number pagination, matching orders.js's/riders.js's
footer — previously hidden while `searchword` was set (a
search result past the first page had no way to reach it),
and used the "count"-only variant (no page-number buttons)
instead of the numbered pager the other two tables use. */}
{filteredTenantList.length > 0 && (
<>
<Divider />
<HStack justify="between" vAlign="center" gap={2} wrap="wrap" paddingInline={4} paddingBlock={2}>
<Text type="supporting" weight="bold" color="secondary" hasTabularNumbers>
{`${page * rowsPerPage + 1}–${Math.min((page + 1) * rowsPerPage, filteredTenantList.length)} of ${
filteredTenantList.length
} tenant${filteredTenantList.length === 1 ? '' : 's'}`}
</Text>
<Pagination
page={page + 1}
onChange={(p) => setPage(p - 1)}
totalItems={filteredTenantList.length}
pageSize={rowsPerPage}
pageSizeOptions={[5, 10, 25, 50, 100]}
onPageSizeChange={(size) => {
setRowsPerPage(size);
setPage(0);
}}
size="sm"
label="Tenants pagination"
/>
</HStack>
</>
)}
</DataCard>
</PageShell>
<Dialog isOpen={dialogopen} onOpenChange={(next) => !next && dialogclose()} width={640} purpose="form">
<Layout
header={
<DialogHeader
title={`Client Pricing - (${selectedCustomer.tenantname || ''})`}
onOpenChange={(next) => !next && dialogclose()}
/>
}
content={
<LayoutContent padding={4}>
<Grid columns={{ minWidth: 220, repeat: 'fit' }} gap={3}>
<Selector
label="Select Slab"
options={VEHICLE_TYPE_OPTIONS}
value={selectedPricing.vehicletype || ''}
onChange={(v) => {
setSelectedPricing({ ...selectedPricing, vehicletype: v });
setIsprice(false);
}}
placeholder="Choose vehicle type"
/>
<NumberInput
label="Base Price"
value={!isPrice ? Number(selectedPricing.baseprice) || undefined : 0}
onChange={(v) => setSelectedPricing({ ...selectedPricing, baseprice: v ?? 0 })}
/>
<NumberInput
label="Price/Km"
value={!isPrice ? Number(selectedPricing.priceperkm) || undefined : 0}
onChange={(v) => setSelectedPricing({ ...selectedPricing, priceperkm: v ?? 0 })}
/>
<NumberInput
label="Base Weight (kg)"
value={!isPrice ? Number(selectedPricing.baseweight) || undefined : 0}
onChange={(v) => setSelectedPricing({ ...selectedPricing, baseweight: v ?? 0 })}
/>
<NumberInput
label="Price/Kg"
value={!isPrice ? Number(selectedPricing.priceperkg) || undefined : 0}
onChange={(v) => setSelectedPricing({ ...selectedPricing, priceperkg: v ?? 0 })}
/>
<NumberInput
label="Base Distance (km)"
value={!isPrice ? Number(selectedPricing.basedistance) || undefined : 0}
onChange={(v) => setSelectedPricing({ ...selectedPricing, basedistance: v ?? 0 })}
/>
<NumberInput
label="Handling Charges"
value={!isPrice ? Number(selectedPricing.handlingcharges) || undefined : 0}
onChange={(v) => setSelectedPricing({ ...selectedPricing, handlingcharges: v ?? 0 })}
/>
</Grid>
</LayoutContent>
}
footer={
<LayoutFooter hasDivider>
<HStack gap={2} justify="end">
{value2 == '1' && <Button label="Update Pricing" variant="primary" isDisabled={isPrice} onClick={createpricing} />}
{value0 == 1 && <Button label="Save" variant="primary" isDisabled={isPrice} onClick={createpricing} />}
<Button
label="Cancel"
variant="destructive"
onClick={() => {
setIsprice(true);
setTimeout(() => {
dialogclose();
}, 100);
setSelectedPricing({});
}}
/>
</HStack>
</LayoutFooter>
}
/>
</Dialog>
<TenantLocationsDialog tenant={locationsDialogTenant} open={!!locationsDialogTenant} onClose={() => setLocationsDialogTenant(null)} />
<TenantCustomersDialog tenant={customersDialogTenant} open={!!customersDialogTenant} onClose={() => setCustomersDialogTenant(null)} />
</>
);
};
export default Clients1;