1696 lines
68 KiB
JavaScript
1696 lines
68 KiB
JavaScript
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;
|