remove: invoice, reports (orders/riders summary+logs, profitability), legacy APIs
Dropped entirely rather than adapted to Doormile, per direction: none of
these are needed for the Doormile console right now.
Deleted:
- src/pages/nearle/invoice/ (invoice.js, invoicePreview.js)
- src/pages/nearle/reports/ (ordersSummary, ordersDetails, ridersSummary,
ridersLogs, profitability, plus their only consumers: mapWithRoute.js,
RiderLocationMap.js, RidersRoutes.js)
Removed the corresponding routes (MainRoutes.js) and sidebar entries
(menu-items/nearle.js: the whole "reports" collapse + "invoice" item),
and their locale keys from en.json.
Removed now-orphaned api.js functions that only those pages called:
getreportsummary, getreportlocationsummary, getriderbydelivery, fetchCount,
fetchRidersSummary, fetchinvoiceinsight, fetchdeliverylist, fetchOrders1,
getallriders. Verified each had zero remaining importers before removing.
Kept fetchorderdetails (still used by orders/details.js) and fetchRidersLogs
(still used by Dispatch.js) -- same name, different consumer than the
deleted reports pages.
This commit is contained in:
@@ -1,10 +1,8 @@
|
|||||||
// third-party
|
// third-party
|
||||||
import { FormattedMessage } from 'react-intl';
|
import { FormattedMessage } from 'react-intl';
|
||||||
import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlined';
|
import DirectionsBikeOutlinedIcon from '@mui/icons-material/DirectionsBikeOutlined';
|
||||||
import ReceiptOutlinedIcon from '@mui/icons-material/ReceiptOutlined';
|
|
||||||
import MopedOutlinedIcon from '@mui/icons-material/MopedOutlined';
|
import MopedOutlinedIcon from '@mui/icons-material/MopedOutlined';
|
||||||
import NearMeOutlinedIcon from '@mui/icons-material/NearMeOutlined';
|
import NearMeOutlinedIcon from '@mui/icons-material/NearMeOutlined';
|
||||||
import { TbListDetails } from 'react-icons/tb';
|
|
||||||
|
|
||||||
// assets
|
// assets
|
||||||
import {
|
import {
|
||||||
@@ -24,7 +22,6 @@ import {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
BarChartOutlined,
|
|
||||||
MoneyCollectOutlined,
|
MoneyCollectOutlined,
|
||||||
FileDoneOutlined
|
FileDoneOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
@@ -47,8 +44,6 @@ const icons = {
|
|||||||
TeamOutlined,
|
TeamOutlined,
|
||||||
MailOutlined,
|
MailOutlined,
|
||||||
ImportOutlined,
|
ImportOutlined,
|
||||||
BarChartOutlined,
|
|
||||||
ReceiptOutlinedIcon,
|
|
||||||
NearMeOutlinedIcon,
|
NearMeOutlinedIcon,
|
||||||
DirectionsBikeOutlinedIcon,
|
DirectionsBikeOutlinedIcon,
|
||||||
MopedOutlinedIcon,
|
MopedOutlinedIcon,
|
||||||
@@ -112,59 +107,6 @@ const nearle = {
|
|||||||
type: 'item',
|
type: 'item',
|
||||||
url: '/nearle/riders',
|
url: '/nearle/riders',
|
||||||
icon: DirectionsBikeOutlinedIcon
|
icon: DirectionsBikeOutlinedIcon
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'reports',
|
|
||||||
title: <FormattedMessage id="reports" />,
|
|
||||||
type: 'collapse',
|
|
||||||
icon: icons.BarChartOutlined,
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
id: 'reports',
|
|
||||||
title: <FormattedMessage id="ordersummary" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/nearle/reports/orderssummary',
|
|
||||||
icon: TbListDetails
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'ordersdetails',
|
|
||||||
title: <FormattedMessage id="ordersdetails" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/nearle/reports/ordersdetails',
|
|
||||||
icon: icons.DashboardOutlined
|
|
||||||
// target: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'riderssummary',
|
|
||||||
title: <FormattedMessage id="riderssummary" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/nearle/reports/riderssummary',
|
|
||||||
icon: DirectionsBikeOutlinedIcon
|
|
||||||
// target: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'riderslogs',
|
|
||||||
title: <FormattedMessage id="riderslogs" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/nearle/reports/riderslogs',
|
|
||||||
icon: DirectionsBikeOutlinedIcon
|
|
||||||
// target: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'profitability',
|
|
||||||
title: <FormattedMessage id="profitability" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/nearle/reports/profitability',
|
|
||||||
icon: icons.BarChartOutlined
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'invoice',
|
|
||||||
title: <FormattedMessage id="invoice" />,
|
|
||||||
type: 'item',
|
|
||||||
url: '/nearle/invoice',
|
|
||||||
icon: icons.ReceiptOutlinedIcon
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -592,29 +592,6 @@ export const getriderstatus = async () => {
|
|||||||
return response.data.data;
|
return response.data.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| getreportsummary (orders summary)||============================== //
|
|
||||||
export const getreportsummary = async ({ queryKey }) => {
|
|
||||||
console.log('queryKey for getreportsummary', queryKey);
|
|
||||||
const [appId, tenantid, locationid, startdate, enddate] = queryKey;
|
|
||||||
const response = await axios.get(
|
|
||||||
`${process.env.REACT_APP_URL}/deliveries/getreportsummary/?applocationid=${appId}&tenantid=${tenantid}&locationid=${locationid}&fromdate=${startdate}&todate=${enddate}`
|
|
||||||
);
|
|
||||||
console.log('getreportsummary', response.data.details);
|
|
||||||
|
|
||||||
return response.data.details;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==============================|| getreportlocationsummary (orders summary)||============================== //
|
|
||||||
export const getreportlocationsummary = async ({ queryKey }) => {
|
|
||||||
console.log('queryKey for getreportlocationsummary', queryKey);
|
|
||||||
const [appId, tenantid, locationid, startdate, enddate] = queryKey;
|
|
||||||
const response = await axios.get(
|
|
||||||
`${process.env.REACT_APP_URL}/deliveries/getreportlocationsummary/?applocationid=${appId}&tenantid=${tenantid}&locationid=${locationid}&fromdate=${startdate}&todate=${enddate}`
|
|
||||||
);
|
|
||||||
console.log('getreportlocationsummary', response.data.details);
|
|
||||||
|
|
||||||
return response.data.details;
|
|
||||||
};
|
|
||||||
// ==============================|| fetchorderdetails (orders detail)||============================== //
|
// ==============================|| fetchorderdetails (orders detail)||============================== //
|
||||||
export const fetchorderdetails = async ({ queryKey }) => {
|
export const fetchorderdetails = async ({ queryKey }) => {
|
||||||
console.log('queryKey of fetchorderdetails', queryKey);
|
console.log('queryKey of fetchorderdetails', queryKey);
|
||||||
@@ -635,49 +612,6 @@ export const fetchorderdetails = async ({ queryKey }) => {
|
|||||||
return detailsWithSNo;
|
return detailsWithSNo;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| getriderbydelivery (orders detail)||============================== //
|
|
||||||
|
|
||||||
export const getriderbydelivery = async (startdate, enddate, appId = 0, tenantid = 0, locationid = 0) => {
|
|
||||||
// const [, startdate, enddate] = queryKey;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await axios.get(
|
|
||||||
`${process.env.REACT_APP_URL}/deliveries/getriderbydelivery/?applocationid=${appId}&tenantid=${tenantid}&locationid=${locationid}&fromdate=${startdate}&todate=${enddate}`
|
|
||||||
);
|
|
||||||
return response.data.details || [];
|
|
||||||
} catch (err) {
|
|
||||||
console.log('getriderbydelivery', err.message);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==============================|| fetchCount (orders detail)||============================== //
|
|
||||||
|
|
||||||
export const fetchCount = async ({ queryKey }) => {
|
|
||||||
console.log('queryKey of fetchCount', queryKey);
|
|
||||||
const [appId, startdate, enddate] = queryKey;
|
|
||||||
let url =
|
|
||||||
appId == 0
|
|
||||||
? `${process.env.REACT_APP_URL}/deliveries/deliverysummary/?fromdate=${startdate}&todate=${enddate}`
|
|
||||||
: `${process.env.REACT_APP_URL}/deliveries/deliverysummary/?applocationid=${appId}&fromdate=${startdate}&todate=${enddate}`;
|
|
||||||
|
|
||||||
const response = await axios.get(url);
|
|
||||||
return response.data.details;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==============================|| fetchRidersSummary (riders summary)||============================== //
|
|
||||||
|
|
||||||
export const fetchRidersSummary = async ({ queryKey }) => {
|
|
||||||
console.log('queryKey for fetchRidersSummary', queryKey);
|
|
||||||
const [, appId, startdate, enddate] = queryKey;
|
|
||||||
const response = await axios.get(
|
|
||||||
`${process.env.REACT_APP_URL}/deliveries/getridersummary/?applocationid=${appId}&fromdate=${startdate}&todate=${enddate}`
|
|
||||||
);
|
|
||||||
console.log('fetchRidersSummary', response.data.details);
|
|
||||||
|
|
||||||
return response.data.details;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==============================|| fetchLocations (orders summary))||============================== //
|
// ==============================|| fetchLocations (orders summary))||============================== //
|
||||||
export const fetchLocations = async () => {
|
export const fetchLocations = async () => {
|
||||||
const response = await axios.get(`${process.env.REACT_APP_URL}/partners/getpartners`);
|
const response = await axios.get(`${process.env.REACT_APP_URL}/partners/getpartners`);
|
||||||
@@ -689,21 +623,6 @@ export const fetchLocations = async () => {
|
|||||||
return updatedLocations;
|
return updatedLocations;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| fetchinvoiceinsight (Invoice)||============================== //
|
|
||||||
|
|
||||||
export const fetchinvoiceinsight = async () => {
|
|
||||||
const insightResponse = await axios.get(`${process.env.REACT_APP_URL}/invoice/getinvoiceinsight`);
|
|
||||||
return insightResponse.data.details;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ==============================|| fetchdeliverylist (Invoice)||============================== //
|
|
||||||
|
|
||||||
export const fetchdeliverylist = async ({ queryKey }) => {
|
|
||||||
const [billStatus] = queryKey;
|
|
||||||
const deliveyResponse = await axios.get(`${process.env.REACT_APP_URL}/invoice/getallinvoice/?billstatus=${billStatus}`);
|
|
||||||
console.log('fetchdeliverylist', deliveyResponse.data.details);
|
|
||||||
return deliveyResponse.data.details;
|
|
||||||
};
|
|
||||||
// ==============================|| fetchRidersLogs (RiderLogs)||============================== //
|
// ==============================|| fetchRidersLogs (RiderLogs)||============================== //
|
||||||
|
|
||||||
export const fetchRidersLogs = async ({ queryKey }) => {
|
export const fetchRidersLogs = async ({ queryKey }) => {
|
||||||
@@ -716,21 +635,6 @@ export const fetchRidersLogs = async ({ queryKey }) => {
|
|||||||
return riderLogsResponse.data.details;
|
return riderLogsResponse.data.details;
|
||||||
};
|
};
|
||||||
|
|
||||||
// ==============================|| getorders (Locations)||============================== //
|
|
||||||
// fetchOrders.js
|
|
||||||
|
|
||||||
export const fetchOrders1 = async ({ pageParam = 1, queryKey }) => {
|
|
||||||
const [, tenantid, locationid, status, startdate, enddate, searchword, rowsPerPage] = queryKey;
|
|
||||||
|
|
||||||
const res = await axios.get(
|
|
||||||
`${process.env.REACT_APP_URL}/orders/tenant/getorders/?tenantid=${tenantid}&locationid=${locationid}&status=${status}&fromdate=${startdate}&todate=${enddate}&pageno=${pageParam}&pagesize=${rowsPerPage}&keyword=${searchword}`
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
details: res.data.details,
|
|
||||||
nextPage: res.data.details.length === rowsPerPage ? pageParam + 1 : undefined
|
|
||||||
};
|
|
||||||
};
|
|
||||||
// ==============================|| getusers (viewProfile)||============================== //
|
// ==============================|| getusers (viewProfile)||============================== //
|
||||||
|
|
||||||
export const getusers = async () => {
|
export const getusers = async () => {
|
||||||
@@ -741,13 +645,3 @@ export const getusers = async () => {
|
|||||||
console.log('getusers', err.message);
|
console.log('getusers', err.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
// ==============================|| getallriders (order)||============================== //
|
|
||||||
|
|
||||||
export const getallriders = async () => {
|
|
||||||
try {
|
|
||||||
const res = await axios.get(`${process.env.REACT_APP_URL}/partners/getallriders?partnerid=64`);
|
|
||||||
return res.data.details;
|
|
||||||
} catch (err) {
|
|
||||||
console.log('getallriders', err.message);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,859 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { Outlet, useNavigate } from 'react-router-dom';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
|
||||||
dayjs.extend(utc);
|
|
||||||
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Divider,
|
|
||||||
Grid,
|
|
||||||
IconButton,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TablePagination,
|
|
||||||
TableRow,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
useMediaQuery
|
|
||||||
} from '@mui/material';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import {
|
|
||||||
MdReceiptLong,
|
|
||||||
MdDashboard,
|
|
||||||
MdHourglassEmpty,
|
|
||||||
MdReportProblem,
|
|
||||||
MdCheckCircle,
|
|
||||||
MdGroups,
|
|
||||||
MdEventNote,
|
|
||||||
MdCurrencyRupee,
|
|
||||||
MdVisibility,
|
|
||||||
MdInventory2,
|
|
||||||
MdOutlinePendingActions,
|
|
||||||
MdOutlineCheckCircle
|
|
||||||
} from 'react-icons/md';
|
|
||||||
|
|
||||||
import { fetchinvoiceinsight, fetchdeliverylist } from 'pages/api/api';
|
|
||||||
import Loader from 'components/Loader';
|
|
||||||
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
|
||||||
import PageHeader from 'components/nearle_components/PageHeader';
|
|
||||||
import StatCard from 'components/nearle_components/StatCard';
|
|
||||||
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
|
||||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Design tokens — shared with deliveries / tenants / customers / pricing /
|
|
||||||
// orders-details / riders-summary pages.
|
|
||||||
// ============================================================================
|
|
||||||
const DT = {
|
|
||||||
radiusPill: 999,
|
|
||||||
radiusCard: 14,
|
|
||||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
|
||||||
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
|
||||||
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
|
||||||
textPrimary: '#0f172a',
|
|
||||||
textSecondary: '#64748b',
|
|
||||||
textMuted: '#94a3b8',
|
|
||||||
borderSubtle: '#e2e8f0',
|
|
||||||
divider: '#f1f5f9',
|
|
||||||
surface: '#ffffff',
|
|
||||||
surfaceAlt: '#f8fafc'
|
|
||||||
};
|
|
||||||
const a = (c, suffix) => `${c}${suffix}`;
|
|
||||||
const tint = (c) => a(c, '08');
|
|
||||||
const soft = (c) => a(c, '18');
|
|
||||||
const ring = (c) => a(c, '26');
|
|
||||||
const edge = (c) => a(c, '55');
|
|
||||||
|
|
||||||
const BRAND = '#C01227';
|
|
||||||
|
|
||||||
const AccentAvatar = ({ color, selected, size = 24, children }) => (
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
bgcolor: selected ? color : soft(color),
|
|
||||||
color: selected ? '#fff' : color,
|
|
||||||
transition: 'background-color 0.15s, color 0.15s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Bill status → tab visual meta (semantic colours; brand purple reserved for "All").
|
|
||||||
const STATUS_META = {
|
|
||||||
0: { key: 'all', label: 'All', color: BRAND, icon: MdDashboard, countKey: 'totalcount' },
|
|
||||||
1: { key: 'open', label: 'Open', color: '#ef4444', icon: MdHourglassEmpty, countKey: 'pendingcount' },
|
|
||||||
2: { key: 'overdue', label: 'Overdue', color: '#f59e0b', icon: MdReportProblem, countKey: 'overduecount' },
|
|
||||||
3: { key: 'paid', label: 'Paid', color: '#10b981', icon: MdCheckCircle, countKey: 'paidcount' }
|
|
||||||
};
|
|
||||||
const STATUS_TABS = [0, 1, 2, 3];
|
|
||||||
|
|
||||||
function formatNumberToRupees(value) {
|
|
||||||
return new Intl.NumberFormat('en-IN', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'INR',
|
|
||||||
minimumFractionDigits: 2
|
|
||||||
}).format(Number(value) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const Invoice = () => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const [page, setPage] = useState(0);
|
|
||||||
const [rowsPerPage, setRowsPerPage] = useState(10);
|
|
||||||
const [billStatus, setBillStatus] = useState(0);
|
|
||||||
const [isloader, setIsLoader] = useState(false);
|
|
||||||
const [searchword, setSearchword] = useState('');
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
|
|
||||||
const handleDebouncedSearch = React.useCallback((val) => {
|
|
||||||
setDebouncedSearch(val);
|
|
||||||
setPage(0);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ============================================= || fetchinvoiceinsight ||
|
|
||||||
const {
|
|
||||||
data: insightdata,
|
|
||||||
isLoading: isInsightLoading,
|
|
||||||
isError: isInsightError,
|
|
||||||
error: insightError
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: ['invoiceInsight'],
|
|
||||||
queryFn: fetchinvoiceinsight,
|
|
||||||
refetchInterval: 300000
|
|
||||||
});
|
|
||||||
|
|
||||||
// ============================================= || fetchdeliverylist ||
|
|
||||||
// NOTE: queryKey shape MUST stay `[billStatus]` — `fetchdeliverylist`
|
|
||||||
// destructures `const [billStatus] = queryKey`.
|
|
||||||
const {
|
|
||||||
data: deliveryList,
|
|
||||||
isLoading: isDeliveryLoading,
|
|
||||||
isError: isDeliveryError,
|
|
||||||
error: deliveryError
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [billStatus],
|
|
||||||
queryFn: fetchdeliverylist,
|
|
||||||
refetchInterval: 300000
|
|
||||||
});
|
|
||||||
|
|
||||||
const isLoading = isInsightLoading || isDeliveryLoading;
|
|
||||||
const isError = isInsightError || isDeliveryError;
|
|
||||||
const errorMessage = insightError?.message || deliveryError?.message;
|
|
||||||
|
|
||||||
// Client-side filter across tenant name, contact person, invoice number.
|
|
||||||
const filteredList = useMemo(() => {
|
|
||||||
if (!deliveryList) return [];
|
|
||||||
if (!debouncedSearch) return deliveryList;
|
|
||||||
const q = debouncedSearch.toLowerCase().trim();
|
|
||||||
return deliveryList.filter((row) =>
|
|
||||||
[row.tenantname, row.contactperson, String(row.invoiceno)]
|
|
||||||
.filter(Boolean)
|
|
||||||
.some((field) => String(field).toLowerCase().includes(q))
|
|
||||||
);
|
|
||||||
}, [deliveryList, debouncedSearch]);
|
|
||||||
|
|
||||||
const activePage = useMemo(() => {
|
|
||||||
const maxPage = Math.max(0, Math.ceil(filteredList.length / rowsPerPage) - 1);
|
|
||||||
return Math.min(page, maxPage);
|
|
||||||
}, [filteredList.length, page, rowsPerPage]);
|
|
||||||
|
|
||||||
// Keep page state in sync when filters or data updates shrink the list below current page
|
|
||||||
React.useEffect(() => {
|
|
||||||
if (page !== activePage) {
|
|
||||||
setPage(activePage);
|
|
||||||
}
|
|
||||||
}, [page, activePage]);
|
|
||||||
|
|
||||||
const pagedList = useMemo(
|
|
||||||
() => filteredList.slice(activePage * rowsPerPage, activePage * rowsPerPage + rowsPerPage),
|
|
||||||
[filteredList, activePage, rowsPerPage]
|
|
||||||
);
|
|
||||||
|
|
||||||
const grandTotal = useMemo(
|
|
||||||
() => filteredList.reduce((sum, r) => sum + (Number(r.totalamount) || 0), 0),
|
|
||||||
[filteredList]
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageTotal = useMemo(
|
|
||||||
() => pagedList.reduce((sum, r) => sum + (Number(r.totalamount) || 0), 0),
|
|
||||||
[pagedList]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleChangePage = (event, newPage) => setPage(newPage);
|
|
||||||
const handleChangeRowsPerPage = (event) => {
|
|
||||||
setRowsPerPage(+event?.target?.value);
|
|
||||||
setPage(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return errorMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KPI_META = [
|
|
||||||
{ idx: 0, label: 'All Invoices', color: BRAND, icon: MdDashboard, value: insightdata?.totalcount ?? 0 },
|
|
||||||
{ idx: 1, label: 'Open', color: '#ef4444', icon: MdOutlinePendingActions, value: insightdata?.pendingcount ?? 0 },
|
|
||||||
{ idx: 2, label: 'Overdue', color: '#f59e0b', icon: MdReportProblem, value: insightdata?.overduecount ?? 0 },
|
|
||||||
{ idx: 3, label: 'Paid', color: '#10b981', icon: MdOutlineCheckCircle, value: insightdata?.paidcount ?? 0 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const activeMeta = STATUS_META[billStatus];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{(isloader || isLoading) && <Loader />}
|
|
||||||
|
|
||||||
{/* ============================================= || Header || ============================================= */}
|
|
||||||
<PageHeader
|
|
||||||
title="Invoices"
|
|
||||||
subtitle={`Live · Viewing ${activeMeta.label.toLowerCase()} invoices`}
|
|
||||||
live
|
|
||||||
action={
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 1,
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.875,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: '#ffffff',
|
|
||||||
border: `1.5px solid ${edge(BRAND)}`,
|
|
||||||
color: BRAND,
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 12
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdCurrencyRupee size={14} />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.4, textTransform: 'uppercase' }}>
|
|
||||||
Grand Total
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ fontWeight: 800, color: BRAND, fontSize: 13 }}>
|
|
||||||
{formatNumberToRupees(grandTotal)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ============================================= || KPI Cards (clickable filter) || ============================================= */}
|
|
||||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
|
||||||
{KPI_META.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Grid item key={item.idx} xs={6} sm={6} md={3}>
|
|
||||||
<Box
|
|
||||||
onClick={() => {
|
|
||||||
setBillStatus(item.idx);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
sx={{ cursor: 'pointer', height: '100%' }}
|
|
||||||
>
|
|
||||||
<StatCard
|
|
||||||
title={item.label}
|
|
||||||
value={item.value}
|
|
||||||
icon={<Icon size={20} />}
|
|
||||||
color={item.color}
|
|
||||||
loading={isInsightLoading}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* ============================================= || Status Tabs + Search || ============================================= */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
mt: { xs: 1.5, md: 2 },
|
|
||||||
p: { xs: 1, md: 1.5 },
|
|
||||||
borderTopLeftRadius: DT.radiusCard / 8,
|
|
||||||
borderTopRightRadius: DT.radiusCard / 8,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: DT.borderSubtle,
|
|
||||||
borderBottom: 0,
|
|
||||||
background: '#fff'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
justifyContent="space-between"
|
|
||||||
gap={1.5}
|
|
||||||
sx={{ flexWrap: 'wrap-reverse' }}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
spacing={0.75}
|
|
||||||
sx={{
|
|
||||||
flex: 1,
|
|
||||||
overflowX: 'auto',
|
|
||||||
py: 0.5,
|
|
||||||
px: 0.25,
|
|
||||||
'&::-webkit-scrollbar': { height: 6 },
|
|
||||||
'&::-webkit-scrollbar-thumb': { backgroundColor: DT.borderSubtle, borderRadius: 4 }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{STATUS_TABS.map((idx) => {
|
|
||||||
const meta = STATUS_META[idx];
|
|
||||||
const Icon = meta.icon;
|
|
||||||
const active = billStatus === idx;
|
|
||||||
const count = insightdata?.[meta.countKey] ?? 0;
|
|
||||||
return (
|
|
||||||
<Box
|
|
||||||
key={idx}
|
|
||||||
onClick={() => {
|
|
||||||
setBillStatus(idx);
|
|
||||||
setPage(0);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: { xs: 0.625, md: 0.875 },
|
|
||||||
pl: 0.5,
|
|
||||||
pr: { xs: 1, md: 1.25 },
|
|
||||||
py: 0.5,
|
|
||||||
flexShrink: 0,
|
|
||||||
cursor: 'pointer',
|
|
||||||
borderRadius: 999,
|
|
||||||
border: `1px solid ${active ? meta.color : DT.borderSubtle}`,
|
|
||||||
bgcolor: active ? meta.color : DT.surface,
|
|
||||||
color: active ? '#fff' : DT.textSecondary,
|
|
||||||
fontWeight: 600,
|
|
||||||
boxShadow: 'none',
|
|
||||||
transition: 'background-color 0.15s, border-color 0.15s, color 0.15s',
|
|
||||||
'&:hover': {
|
|
||||||
borderColor: active ? meta.color : '#cbd5e1',
|
|
||||||
bgcolor: active ? meta.color : DT.surfaceAlt
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
width: { xs: 20, md: 22 },
|
|
||||||
height: { xs: 20, md: 22 },
|
|
||||||
bgcolor: active ? 'rgba(255,255,255,0.22)' : soft(meta.color),
|
|
||||||
color: active ? '#fff' : meta.color
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon size={12} />
|
|
||||||
</Avatar>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 600,
|
|
||||||
fontSize: { xs: 11.5, md: 13 },
|
|
||||||
lineHeight: 1
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{meta.label}
|
|
||||||
</Typography>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
minWidth: { xs: 20, md: 24 },
|
|
||||||
height: { xs: 18, md: 20 },
|
|
||||||
px: 0.625,
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
borderRadius: 999,
|
|
||||||
fontSize: { xs: 10, md: 11 },
|
|
||||||
fontWeight: 700,
|
|
||||||
bgcolor: active ? 'rgba(255,255,255,0.22)' : DT.surfaceAlt,
|
|
||||||
color: active ? '#fff' : DT.textSecondary,
|
|
||||||
border: 'none'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{count}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Box sx={{ width: { xs: '100%', sm: 240, lg: 280 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
|
|
||||||
<DebounceSearchBar
|
|
||||||
value={searchword}
|
|
||||||
onChange={setSearchword}
|
|
||||||
onDebouncedChange={handleDebouncedSearch}
|
|
||||||
placeholder="Search invoices (ctrl+k)"
|
|
||||||
sx={{
|
|
||||||
m: 0,
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: '#ffffff',
|
|
||||||
'& fieldset': { borderColor: '#e2e8f0', borderWidth: 1 },
|
|
||||||
'&:hover fieldset': { borderColor: '#cbd5e1' },
|
|
||||||
'&.Mui-focused fieldset': { borderColor: '#C01227', borderWidth: 1.5 },
|
|
||||||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* ============================================= || Table || ============================================= */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderTopRightRadius: 0,
|
|
||||||
borderBottomLeftRadius: DT.radiusCard / 8,
|
|
||||||
borderBottomRightRadius: DT.radiusCard / 8,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: DT.borderSubtle,
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: '#fff'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
<>
|
|
||||||
{isDeliveryLoading ? (
|
|
||||||
<Box sx={{ p: 1.5 }}>
|
|
||||||
<OrdersTableSkeleton col={4} />
|
|
||||||
</Box>
|
|
||||||
) : pagedList.length === 0 ? (
|
|
||||||
<Stack alignItems="center" spacing={1.5} sx={{ py: 6, px: 2 }}>
|
|
||||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
|
||||||
<MdReceiptLong size={28} />
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
||||||
No invoices to show
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center' }}>
|
|
||||||
{searchword
|
|
||||||
? 'Try a different keyword.'
|
|
||||||
: `No ${activeMeta.label.toLowerCase()} invoices for this filter.`}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
<MobileCardList>
|
|
||||||
{pagedList.map((item, index) => {
|
|
||||||
const overdue =
|
|
||||||
billStatus === 2 ||
|
|
||||||
(item.duedate && dayjs(item.duedate).isBefore(dayjs(), 'day') && billStatus !== 3);
|
|
||||||
return (
|
|
||||||
<MobileCard
|
|
||||||
key={item.invoiceno || index}
|
|
||||||
accent={BRAND}
|
|
||||||
header={
|
|
||||||
<Stack direction="row" alignItems="flex-start" justifyContent="space-between" spacing={1}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1} sx={{ minWidth: 0 }}>
|
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
|
||||||
<MdGroups size={18} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<Stack spacing={0.25} sx={{ minWidth: 0 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
|
|
||||||
{item.tenantname || '—'}
|
|
||||||
</Typography>
|
|
||||||
{item.contactperson && (
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }} noWrap>
|
|
||||||
{item.contactperson}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Tooltip title="Preview invoice" placement="left">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setIsLoader(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoader(false);
|
|
||||||
navigate('/nearle/invoice/preview', { state: item });
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
flexShrink: 0,
|
|
||||||
bgcolor: soft(BRAND),
|
|
||||||
color: BRAND,
|
|
||||||
border: `1px solid ${edge(BRAND)}`,
|
|
||||||
'&:hover': { bgcolor: BRAND, color: '#fff' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdVisibility size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MobileFieldGrid>
|
|
||||||
<MobileField label="Invoice ID">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 1,
|
|
||||||
py: 0.375,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: tint('#0ea5e9'),
|
|
||||||
border: `1px solid ${edge('#0ea5e9')}`,
|
|
||||||
color: '#0ea5e9',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdReceiptLong size={12} /> {item.invoiceno || '—'}
|
|
||||||
</Box>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label="Amount" align="right">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 1,
|
|
||||||
py: 0.375,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: '#ffffff',
|
|
||||||
border: `1px solid ${edge(BRAND)}`,
|
|
||||||
color: BRAND,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdCurrencyRupee size={11} />
|
|
||||||
{formatNumberToRupees(item.totalamount).replace('₹', '').trim()}
|
|
||||||
</Box>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label="Invoice Date">
|
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
||||||
<MdEventNote size={12} color={DT.textMuted} />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textPrimary }} noWrap>
|
|
||||||
{item.transactiondate ? dayjs(item.transactiondate).format('DD/MM/YYYY') : '—'}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label="Due Date">
|
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
||||||
<MdEventNote size={12} color={overdue && billStatus !== 3 ? '#ef4444' : DT.textMuted} />
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
color: overdue && billStatus !== 3 ? '#ef4444' : DT.textPrimary
|
|
||||||
}}
|
|
||||||
noWrap
|
|
||||||
>
|
|
||||||
{item.duedate ? dayjs(item.duedate).format('DD/MM/YYYY') : '—'}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label="Items">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 0.875,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: tint('#14b8a6'),
|
|
||||||
border: `1px solid ${edge('#14b8a6')}`,
|
|
||||||
color: '#14b8a6',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
minWidth: 44,
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdInventory2 size={11} /> {item.itemcount ?? 0}
|
|
||||||
</Box>
|
|
||||||
</MobileField>
|
|
||||||
</MobileFieldGrid>
|
|
||||||
</MobileCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MobileCardList>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<TableContainer
|
|
||||||
sx={{
|
|
||||||
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
|
|
||||||
'&::-webkit-scrollbar': { width: 10, height: 10 },
|
|
||||||
'&::-webkit-scrollbar-thumb': {
|
|
||||||
backgroundColor: edge(BRAND),
|
|
||||||
borderRadius: 8,
|
|
||||||
'&:hover': { backgroundColor: BRAND }
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table stickyHeader sx={{ minWidth: { xs: 880, md: 1060 } }}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow
|
|
||||||
sx={{
|
|
||||||
'& th': {
|
|
||||||
backgroundColor: DT.surfaceAlt,
|
|
||||||
color: DT.textSecondary,
|
|
||||||
fontSize: { xs: 10, md: 11 },
|
|
||||||
fontWeight: 800,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
borderBottom: `1px solid ${DT.borderSubtle}`,
|
|
||||||
py: { xs: 1, md: 1.25 },
|
|
||||||
px: { xs: 1, md: 2 }
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>#</TableCell>
|
|
||||||
<TableCell>Client</TableCell>
|
|
||||||
<TableCell>Invoice ID</TableCell>
|
|
||||||
<TableCell>Invoice Date</TableCell>
|
|
||||||
<TableCell>Due Date</TableCell>
|
|
||||||
<TableCell align="center">Items</TableCell>
|
|
||||||
<TableCell align="right">Amount</TableCell>
|
|
||||||
<TableCell align="center">Action</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
|
|
||||||
<TableBody>
|
|
||||||
{isDeliveryLoading && <OrdersTableSkeleton col={4} />}
|
|
||||||
{!isDeliveryLoading && pagedList.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={8} sx={{ py: 6 }}>
|
|
||||||
<Stack alignItems="center" spacing={1.5}>
|
|
||||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
|
||||||
<MdReceiptLong size={28} />
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
||||||
No invoices to show
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
||||||
{searchword
|
|
||||||
? 'Try a different keyword.'
|
|
||||||
: `No ${activeMeta.label.toLowerCase()} invoices for this filter.`}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
pagedList.map((item, index) => {
|
|
||||||
const overdue = billStatus === 2 || (item.duedate && dayjs(item.duedate).isBefore(dayjs(), 'day') && billStatus !== 3);
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={item.invoiceno || index}
|
|
||||||
sx={{
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
'& td': {
|
|
||||||
borderBottom: `1px solid ${DT.divider}`,
|
|
||||||
py: { xs: 1, md: 1.5 },
|
|
||||||
px: { xs: 1, md: 2 },
|
|
||||||
verticalAlign: 'top'
|
|
||||||
},
|
|
||||||
'&:hover': { backgroundColor: DT.surfaceAlt }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
|
|
||||||
{String(activePage * rowsPerPage + index + 1).padStart(2, '0')}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
|
||||||
<MdGroups size={18} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<Stack spacing={0.25} sx={{ minWidth: 0 }}>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
|
|
||||||
{item.tenantname || '—'}
|
|
||||||
</Typography>
|
|
||||||
{item.contactperson && (
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
||||||
{item.contactperson}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 1,
|
|
||||||
py: 0.375,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: tint('#0ea5e9'),
|
|
||||||
border: `1px solid ${edge('#0ea5e9')}`,
|
|
||||||
color: '#0ea5e9',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdReceiptLong size={12} /> {item.invoiceno || '—'}
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Stack spacing={0.25}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
||||||
<MdEventNote size={12} color={DT.textMuted} />
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
|
|
||||||
{item.transactiondate ? dayjs(item.transactiondate).format('DD/MM/YYYY') : '—'}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary, pl: 2 }}>
|
|
||||||
{item.transactiondate ? dayjs(item.transactiondate).utc().format('hh:mm A') : ''}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Stack spacing={0.25}>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
||||||
<MdEventNote
|
|
||||||
size={12}
|
|
||||||
color={overdue && billStatus !== 3 ? '#ef4444' : DT.textMuted}
|
|
||||||
/>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{
|
|
||||||
fontWeight: 700,
|
|
||||||
color: overdue && billStatus !== 3 ? '#ef4444' : DT.textPrimary,
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{item.duedate ? dayjs(item.duedate).format('DD/MM/YYYY') : '—'}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary, pl: 2 }}>
|
|
||||||
{item.duedate ? dayjs(item.duedate).utc().format('hh:mm A') : ''}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell align="center">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 0.875,
|
|
||||||
py: 0.25,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: tint('#14b8a6'),
|
|
||||||
border: `1px solid ${edge('#14b8a6')}`,
|
|
||||||
color: '#14b8a6',
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
minWidth: 44,
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdInventory2 size={11} /> {item.itemcount ?? 0}
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell align="right">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 1,
|
|
||||||
py: 0.375,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: '#ffffff',
|
|
||||||
border: `1px solid ${edge(BRAND)}`,
|
|
||||||
color: BRAND,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
minWidth: 110,
|
|
||||||
justifyContent: 'center'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdCurrencyRupee size={11} />
|
|
||||||
{formatNumberToRupees(item.totalamount).replace('₹', '').trim()}
|
|
||||||
</Box>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell align="center">
|
|
||||||
<Tooltip title="Preview invoice" placement="left">
|
|
||||||
<IconButton
|
|
||||||
size="small"
|
|
||||||
onClick={() => {
|
|
||||||
setIsLoader(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setIsLoader(false);
|
|
||||||
navigate('/nearle/invoice/preview', { state: item });
|
|
||||||
}, 500);
|
|
||||||
}}
|
|
||||||
sx={{
|
|
||||||
bgcolor: soft(BRAND),
|
|
||||||
color: BRAND,
|
|
||||||
border: `1px solid ${edge(BRAND)}`,
|
|
||||||
'&:hover': { bgcolor: BRAND, color: '#fff' }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdVisibility size={16} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
|
||||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
|
||||||
justifyContent="space-between"
|
|
||||||
sx={{
|
|
||||||
px: 2,
|
|
||||||
py: 1,
|
|
||||||
background: '#ffffff'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}>
|
|
||||||
Page total · {formatNumberToRupees(pageTotal)}
|
|
||||||
</Typography>
|
|
||||||
<TablePagination
|
|
||||||
rowsPerPageOptions={[5, 10, 25, 100]}
|
|
||||||
component="div"
|
|
||||||
count={filteredList.length}
|
|
||||||
rowsPerPage={rowsPerPage}
|
|
||||||
page={activePage}
|
|
||||||
onPageChange={handleChangePage}
|
|
||||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
|
||||||
sx={{
|
|
||||||
'& .MuiTablePagination-toolbar': { minHeight: 40, px: 0 },
|
|
||||||
'& .MuiTablePagination-selectLabel, & .MuiTablePagination-displayedRows': {
|
|
||||||
fontWeight: 700,
|
|
||||||
color: DT.textSecondary
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
<Outlet />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Invoice;
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
import React, { useRef, useState, useEffect } from 'react';
|
|
||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import useMediaQuery from '@mui/material/useMediaQuery';
|
|
||||||
// import nearleLogo from '../../../assets/images/nearleLogo.png';
|
|
||||||
import logo_nearle1 from '../../../assets/images/doormile-logo.png';
|
|
||||||
|
|
||||||
// doormile-logo.png is a white asset; this recolours it to brand red (#C01227) for the light invoice background.
|
|
||||||
const DOORMILE_RED_FILTER = 'brightness(0) saturate(100%) invert(15%) sepia(93%) saturate(5437%) hue-rotate(346deg) brightness(81%) contrast(92%)';
|
|
||||||
import axios from 'axios';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { enqueueSnackbar } from 'notistack';
|
|
||||||
import { PrinterFilled } from '@ant-design/icons';
|
|
||||||
import ReactToPrint from 'react-to-print';
|
|
||||||
// import jsPDF from 'jspdf';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import { FaArrowLeft } from 'react-icons/fa6';
|
|
||||||
import { FaIndianRupeeSign } from 'react-icons/fa6';
|
|
||||||
|
|
||||||
// import autoTable from 'jspdf-autotable';
|
|
||||||
import {
|
|
||||||
Grid,
|
|
||||||
Button,
|
|
||||||
Divider,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Typography,
|
|
||||||
Box,
|
|
||||||
IconButton,
|
|
||||||
TextField,
|
|
||||||
Tooltip,
|
|
||||||
Dialog,
|
|
||||||
DialogTitle,
|
|
||||||
DialogContent,
|
|
||||||
DialogActions,
|
|
||||||
Stack,
|
|
||||||
Chip
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
const InvoicePreview = () => {
|
|
||||||
const [selected, setselected] = useState({});
|
|
||||||
const location = useLocation();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
console.log('previewSelect', location.state);
|
|
||||||
const componentRef = useRef(null);
|
|
||||||
const [paydialog, setpaydialog] = useState(false);
|
|
||||||
const [refnumber, setRefnumber] = useState('');
|
|
||||||
const [remarks, setRemarks] = useState('');
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
useEffect(() => {
|
|
||||||
setselected(location.state);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ================================================= || formatNumberToRupees || =================================================
|
|
||||||
|
|
||||||
function formatNumberToRupees(value) {
|
|
||||||
return new Intl.NumberFormat('en-IN', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'INR',
|
|
||||||
minimumFractionDigits: 2
|
|
||||||
}).format(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ================================================= || updatePayment || =================================================
|
|
||||||
|
|
||||||
const updatePayment = async () => {
|
|
||||||
try {
|
|
||||||
const updateResponse = await axios.put(`${process.env.REACT_APP_URL}/invoice/updatestatus`, {
|
|
||||||
salesid: selected.salesid,
|
|
||||||
referenceno: refnumber,
|
|
||||||
referencedate: dayjs().format('YYYY-MM-DD HH:mm:ss'),
|
|
||||||
billstatus: 2,
|
|
||||||
paymentremarks: remarks
|
|
||||||
});
|
|
||||||
if (updateResponse.status) {
|
|
||||||
enqueueSnackbar(' Updated Successfully ', {
|
|
||||||
variant: 'success',
|
|
||||||
anchorOrigin: { vertical: 'top', horizontal: 'right' },
|
|
||||||
autoHideDuration: 1000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
console.log('updateResponse', updateResponse);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('updateResponse', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: 'column', md: 'row' }}
|
|
||||||
justifyContent="Space-between"
|
|
||||||
alignItems={{ xs: 'stretch', md: 'center' }}
|
|
||||||
spacing={2}
|
|
||||||
sx={{ px: { xs: 1.5, md: 2.5 }, py: 1, bgcolor: '#eeeeee' }}
|
|
||||||
>
|
|
||||||
<Stack direction={'row'} alignItems={'center'} spacing={2}>
|
|
||||||
<Tooltip title="back">
|
|
||||||
<IconButton
|
|
||||||
color="primary"
|
|
||||||
onClick={() => {
|
|
||||||
navigate('/nearle/invoice');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<FaArrowLeft size={'large'} />
|
|
||||||
</IconButton>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<Stack alignItems={'center'}>
|
|
||||||
<Typography variant="h3" color={'primary'}>
|
|
||||||
Invoice Details
|
|
||||||
</Typography>
|
|
||||||
<Chip
|
|
||||||
size="small"
|
|
||||||
color="warning"
|
|
||||||
variant="outlined"
|
|
||||||
sx={{ bgcolor: theme.palette.warning.lighter }}
|
|
||||||
label={`Invoice No :${'\u00a0\u00a0'}${selected.invoiceno}`}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2} sx={{ width: { xs: '100%', md: 'auto' } }}>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
fullWidth={isMobile}
|
|
||||||
sx={{
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
color: 'primary.contrastText'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setpaydialog(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{' '}
|
|
||||||
<FaIndianRupeeSign />
|
|
||||||
Update Payment
|
|
||||||
</Button>
|
|
||||||
<ReactToPrint
|
|
||||||
trigger={() => (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
startIcon={<PrinterFilled />}
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
fullWidth={isMobile}
|
|
||||||
sx={{
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
color: 'primary.contrastText'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Print
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
content={() => componentRef.current}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
<Box sx={{ pb: 2.5, border: '1px solid #eee', overflowX: { xs: 'auto', md: 'visible' } }}>
|
|
||||||
{/* minWidth keeps the invoice at a legible fixed layout on phones —
|
|
||||||
the parent's overflowX:auto then lets it scroll horizontally
|
|
||||||
instead of squishing the header into vertical slivers. 720px sits
|
|
||||||
within the print page width, so printing is unaffected. */}
|
|
||||||
<div ref={componentRef} style={{ width: '100%', minWidth: 720 }}>
|
|
||||||
<Box id="print" sx={{ p: 2.5 }}>
|
|
||||||
<Box sx={{ pb: 2.5 }}>
|
|
||||||
<Stack
|
|
||||||
sx={{
|
|
||||||
flexDirection: 'row',
|
|
||||||
// bgcolor: theme.palette.primary.main,
|
|
||||||
border: '1px solid #eee',
|
|
||||||
px: 3
|
|
||||||
}}
|
|
||||||
justifyContent="space-between"
|
|
||||||
>
|
|
||||||
<Box sx={{ pt: 0.5 }}>
|
|
||||||
<Stack direction="row" spacing={2}>
|
|
||||||
<img src={logo_nearle1} style={{ width: '150px', height: '20px', filter: DOORMILE_RED_FILTER }} />{' '}
|
|
||||||
</Stack>
|
|
||||||
{/* <Typography
|
|
||||||
sx={{ color: theme.palette.primary.main, py: 0.5 }}
|
|
||||||
>
|
|
||||||
{`Invoice No: ${"\u00a0\u00a0\u00a0"}${selected.invoiceno}`}
|
|
||||||
</Typography> */}
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
overflow: 'hidden',
|
|
||||||
color: theme.palette.primary.main
|
|
||||||
}}
|
|
||||||
variant="subtitle1"
|
|
||||||
>
|
|
||||||
Invoice No :
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: theme.palette.primary.main }}>{`${'\u00a0\u00a0'}${selected.invoiceno}`}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
<Box sx={{ pt: 2.5, pb: 1.75 }}>
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography sx={{ pl: 4, color: theme.palette.primary.main }} variant="subtitle1">
|
|
||||||
Date :{' '}
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: theme.palette.primary.main }}>
|
|
||||||
{dayjs(selected.transactiondate).format('DD-MM-YYYY')}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
pr: 2,
|
|
||||||
overflow: 'hidden',
|
|
||||||
color: theme.palette.primary.main
|
|
||||||
}}
|
|
||||||
variant="subtitle1"
|
|
||||||
>
|
|
||||||
Due Date :
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: theme.palette.primary.main }}>{dayjs(selected.dueDate).format('DD-MM-YYYY')}</Typography>
|
|
||||||
</Stack>
|
|
||||||
{/* <Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography
|
|
||||||
sx={{
|
|
||||||
pr: 2,
|
|
||||||
overflow: "hidden",
|
|
||||||
color: theme.palette.primary.main,
|
|
||||||
}}
|
|
||||||
variant="subtitle1"
|
|
||||||
>
|
|
||||||
Invoice No :
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: theme.palette.primary.main }}>
|
|
||||||
{`${"\u00a0\u00a0\u00a0"}${selected.invoiceno}`}
|
|
||||||
</Typography>
|
|
||||||
</Stack> */}
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
<Box sx={{ pt: 2.5 }}>
|
|
||||||
<Grid container spacing={2} justifyContent="space-between" direction="row">
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
border: 1,
|
|
||||||
minHeight: 240,
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
borderRadius: 0.5,
|
|
||||||
p: 2.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid container direction="row">
|
|
||||||
<Grid item md={8}>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Typography variant="h5">From:</Typography>
|
|
||||||
<Stack sx={{ width: '100%' }}>
|
|
||||||
<Typography variant="subtitle1">Nearle Technology Privite Limited.</Typography>
|
|
||||||
<Typography color="secondary">
|
|
||||||
424, 4<sup>th</sup>floor,
|
|
||||||
</Typography>
|
|
||||||
<Typography color="secondary">Red rose towers,</Typography>
|
|
||||||
<Typography color="secondary">DB Road, RS Puram,</Typography>
|
|
||||||
<Typography color="secondary">641002.</Typography>
|
|
||||||
<Typography color="secondary">care@nearle.in</Typography>
|
|
||||||
<Typography color="secondary">9047968666</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
<Grid item xs={12} sm={6}>
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
border: 1,
|
|
||||||
minHeight: 240,
|
|
||||||
borderColor: 'grey.200',
|
|
||||||
borderRadius: 0.5,
|
|
||||||
p: 2.5
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Grid container direction="row">
|
|
||||||
<Grid item md={8}>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Typography variant="h5">To:</Typography>
|
|
||||||
<Stack sx={{ width: '100%' }}>
|
|
||||||
<Typography variant="subtitle1">{selected.tenantname}</Typography>
|
|
||||||
<Typography color="secondary">{selected.address}</Typography>
|
|
||||||
<Typography color="secondary">{selected.suburb}</Typography>
|
|
||||||
<Typography color="secondary">{selected.city}</Typography>
|
|
||||||
<Typography color="secondary">{selected.state}</Typography>{' '}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<TableContainer>
|
|
||||||
<Table>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>S.No</TableCell>
|
|
||||||
<TableCell>Particulars</TableCell>
|
|
||||||
<TableCell>Unit</TableCell>
|
|
||||||
<TableCell>Quantity</TableCell>
|
|
||||||
<TableCell align="right">Rate</TableCell>
|
|
||||||
{/* {selected && selected.pricingtypeid === 73 && ( */}
|
|
||||||
<TableCell align="right">Other Charges</TableCell>
|
|
||||||
{/* )} */}
|
|
||||||
<TableCell align="right">Amount</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
{selected.tenantsalesdetails && (
|
|
||||||
<TableBody>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>1</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography>
|
|
||||||
{`Invoice from ${dayjs(selected.tenantsalesdetails[0].fromdate).format('DD-MM-YYYY')} to ${dayjs(
|
|
||||||
selected.tenantsalesdetails[0].todate
|
|
||||||
).format('DD-MM-YYYY')}`}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography>{selected.tenantsalesdetails[0].pricingtype}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
|
|
||||||
<TableCell>
|
|
||||||
<Typography>{`${selected.tenantsalesdetails[0].quantity.toFixed(2)} km`}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Typography align="right">{`₹ ${selected.tenantsalesdetails[0].baserate.toFixed(2)}`}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
{/* {selected.tenantsalesdetails[0].pricingtypeid == 73 && ( */}
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography>{`₹ ${selected.tenantsalesdetails[0].othercharges}.00`}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
{/* )} */}
|
|
||||||
<TableCell align="right">
|
|
||||||
<Typography>{`₹ ${selected.tenantsalesdetails[0].amount}.00`}</Typography>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
)}
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
<Divider />
|
|
||||||
<Box sx={{ p: 2.5 }}>
|
|
||||||
<Grid container direction="row" justifyContent="flex-end">
|
|
||||||
<Grid item md={4}>
|
|
||||||
<Stack spacing={2}>
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography color="secondary">Sub Total:</Typography>
|
|
||||||
<Typography variant="h6">{formatNumberToRupees(selected.salesamount)}</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography color="secondary">Discount:</Typography>
|
|
||||||
<Typography variant="h6" color={theme.palette.error.main}>
|
|
||||||
- {formatNumberToRupees(selected.discountamt)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography color={theme.palette.grey[500]}>Tax:</Typography>
|
|
||||||
<Typography variant="h6" color={theme.palette.success.main}>
|
|
||||||
+ {formatNumberToRupees(selected.taxamount)}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Stack direction="row" justifyContent="space-between">
|
|
||||||
<Typography sx={{ pr: 2 }} variant="subtitle1">
|
|
||||||
Grand Total:
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h6">{formatNumberToRupees(Math.round(selected.totalamount))}</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Grid>
|
|
||||||
</Grid>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
<Box sx={{ p: 2.5 }}>
|
|
||||||
<Typography>Notes: {selected.remarks}</Typography>
|
|
||||||
</Box>
|
|
||||||
<Divider />
|
|
||||||
</div>
|
|
||||||
</Box>
|
|
||||||
{/* ================================================= || updatePayment Dialog || ================================================= */}
|
|
||||||
<Dialog
|
|
||||||
open={paydialog}
|
|
||||||
onClose={() => {
|
|
||||||
setpaydialog(false);
|
|
||||||
}}
|
|
||||||
maxWidth={'sm'}
|
|
||||||
fullWidth
|
|
||||||
>
|
|
||||||
<DialogTitle sx={{ bgcolor: theme.palette.primary.main }}>
|
|
||||||
<Stack direction={'row'} spacing={1}>
|
|
||||||
<Typography variant="h2" sx={{ color: 'white' }}>
|
|
||||||
₹
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="h3" sx={{ color: 'white' }}>
|
|
||||||
Update Payment
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogContent dividers>
|
|
||||||
<Stack spacing={1} sx={{ mb: 2 }}>
|
|
||||||
<Typography>Reference No</Typography>
|
|
||||||
<TextField
|
|
||||||
type="number"
|
|
||||||
placeholder="Enter Reference Number"
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRefnumber(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack spacing={2} sx={{ mb: 2 }}>
|
|
||||||
<Typography>Remarks</Typography>
|
|
||||||
<TextField
|
|
||||||
multiline
|
|
||||||
required
|
|
||||||
placeholder="Enter Remarks"
|
|
||||||
sx={{ width: '100%' }}
|
|
||||||
onChange={(e) => {
|
|
||||||
setRemarks(e.target.value);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</DialogContent>
|
|
||||||
<DialogActions>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
sx={{
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
color: 'primary.contrastText'
|
|
||||||
},
|
|
||||||
m: 2
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setpaydialog(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
disabled={refnumber == '' || remarks == ''}
|
|
||||||
sx={{
|
|
||||||
'&:hover': {
|
|
||||||
backgroundColor: 'primary.main',
|
|
||||||
color: 'primary.contrastText'
|
|
||||||
},
|
|
||||||
m: 2
|
|
||||||
}}
|
|
||||||
onClick={() => {
|
|
||||||
setpaydialog(false);
|
|
||||||
updatePayment();
|
|
||||||
navigate('/nearle/invoice');
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Update
|
|
||||||
</Button>
|
|
||||||
</DialogActions>
|
|
||||||
</Dialog>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default InvoicePreview;
|
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { Button } from '@mui/material';
|
|
||||||
import { LoadScriptNext, GoogleMap, Marker, OverlayView } from '@react-google-maps/api';
|
|
||||||
|
|
||||||
const containerStyle = {
|
|
||||||
width: '100%',
|
|
||||||
height: 'calc(100vh - 150px)'
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function RiderLocationMap({ riderLocations }) {
|
|
||||||
console.log('riderLocations', riderLocations);
|
|
||||||
|
|
||||||
const center = {
|
|
||||||
lat: Number(riderLocations?.[0]?.latitude || 11.0056),
|
|
||||||
lng: Number(riderLocations?.[0]?.longitude || 76.9661)
|
|
||||||
};
|
|
||||||
const GreenIcon = {
|
|
||||||
url: 'https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
|
|
||||||
scaledSize: new window.google.maps.Size(25, 41),
|
|
||||||
anchor: new window.google.maps.Point(12, 41)
|
|
||||||
};
|
|
||||||
|
|
||||||
const RedIcon = {
|
|
||||||
url: 'https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
|
||||||
scaledSize: new window.google.maps.Size(25, 41),
|
|
||||||
anchor: new window.google.maps.Point(12, 41)
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<LoadScriptNext googleMapsApiKey={process.env.REACT_APP_GOOGLE_MAPS_API_KEY}>
|
|
||||||
<GoogleMap mapContainerStyle={containerStyle} zoom={12} center={center}>
|
|
||||||
{riderLocations &&
|
|
||||||
riderLocations?.map((r, index) => {
|
|
||||||
const lat = Number(r.latitude);
|
|
||||||
const lng = Number(r.longitude);
|
|
||||||
return (
|
|
||||||
<div key={index}>
|
|
||||||
{/* Marker */}
|
|
||||||
<Marker
|
|
||||||
position={{ lat, lng }}
|
|
||||||
icon={r.status == 'active' ? GreenIcon : RedIcon}
|
|
||||||
label={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 'bold'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<OverlayView position={{ lat, lng }} mapPaneName={OverlayView.OVERLAY_LAYER}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
background: 'none',
|
|
||||||
color: 'green',
|
|
||||||
padding: '2px 8px',
|
|
||||||
borderRadius: '4px',
|
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 600,
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
transform: 'translate(-50%, -140%)',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
ml: 20
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button variant="contained" color="primary" size="small">
|
|
||||||
{` ${r.username} `}
|
|
||||||
{/* <br /> */}
|
|
||||||
{/* {`${r.contactno || '##### ##### '} `} */}
|
|
||||||
<br />
|
|
||||||
{`(${r.orderid || ''}) `}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</OverlayView>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</GoogleMap>
|
|
||||||
</LoadScriptNext>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { GoogleMap, Polyline, Marker, InfoWindow, useJsApiLoader } from '@react-google-maps/api';
|
|
||||||
import { Box, IconButton, Stack, Typography, CircularProgress } from '@mui/material';
|
|
||||||
import { MdClose, MdRoute } from 'react-icons/md';
|
|
||||||
|
|
||||||
const containerStyle = { width: '100%', height: '100%' };
|
|
||||||
|
|
||||||
// Renders a single rider's PLANNED route for the date range chosen on the
|
|
||||||
// Riders Summary page. `details` is an ordered array of waypoints (sorted by
|
|
||||||
// the planning step number) shaped as:
|
|
||||||
// { step, orderid, deliveryid, customer, address,
|
|
||||||
// dropLat, dropLng, pickLat, pickLng, expectedTime }
|
|
||||||
// `dropLat/dropLng` are required; pickup coords are optional and rendered as
|
|
||||||
// faded pre-stops if present.
|
|
||||||
export default function RidersRoutes({ details, loading, riderName, dateRange, onClose }) {
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
const [focusedStep, setFocusedStep] = useState(null);
|
|
||||||
const [routePath, setRoutePath] = useState([]);
|
|
||||||
const [routeLoading, setRouteLoading] = useState(false);
|
|
||||||
|
|
||||||
const { isLoaded } = useJsApiLoader({
|
|
||||||
googleMapsApiKey: process.env.REACT_APP_GOOGLE_MAPS_KEY
|
|
||||||
});
|
|
||||||
|
|
||||||
// Step-pin coordinates in planning order — what the polyline connects.
|
|
||||||
const dropPath = useMemo(
|
|
||||||
() => (details || []).map((d) => ({ lat: d.dropLat, lng: d.dropLng })),
|
|
||||||
[details]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Auto-fit map bounds to the full planned path once the map and data are
|
|
||||||
// both ready. Re-runs whenever the route changes (different rider / date).
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoaded || !mapRef.current || dropPath.length === 0) return;
|
|
||||||
const bounds = new window.google.maps.LatLngBounds();
|
|
||||||
dropPath.forEach((p) => bounds.extend(p));
|
|
||||||
mapRef.current.fitBounds(bounds, 48);
|
|
||||||
}, [isLoaded, dropPath]);
|
|
||||||
|
|
||||||
// Resolve the rider's planned waypoints into an actual road-following path
|
|
||||||
// via the Directions API. Without this, the polyline would cut across
|
|
||||||
// buildings / aerial lines — operators have no way to read the real route.
|
|
||||||
// Directions has a 25-waypoint limit per request, so we chunk and stitch.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isLoaded || dropPath.length < 2) {
|
|
||||||
setRoutePath([]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let cancelled = false;
|
|
||||||
const ds = new window.google.maps.DirectionsService();
|
|
||||||
const MAX_WPS = 23; // origin + 23 waypoints + destination = 25 stops/chunk
|
|
||||||
|
|
||||||
const fetchSegment = (origin, destination, waypoints) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
ds.route(
|
|
||||||
{
|
|
||||||
origin,
|
|
||||||
destination,
|
|
||||||
waypoints: waypoints.map((p) => ({ location: p, stopover: true })),
|
|
||||||
travelMode: window.google.maps.TravelMode.DRIVING
|
|
||||||
},
|
|
||||||
(result, status) => {
|
|
||||||
if (status === 'OK') resolve(result);
|
|
||||||
else reject(new Error(status));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
(async () => {
|
|
||||||
setRouteLoading(true);
|
|
||||||
try {
|
|
||||||
const points = dropPath;
|
|
||||||
const all = [];
|
|
||||||
let i = 0;
|
|
||||||
while (i < points.length - 1) {
|
|
||||||
const remaining = points.length - 1 - i;
|
|
||||||
const take = Math.min(remaining, MAX_WPS + 1);
|
|
||||||
const origin = points[i];
|
|
||||||
const destination = points[i + take];
|
|
||||||
const waypoints = points.slice(i + 1, i + take);
|
|
||||||
const res = await fetchSegment(origin, destination, waypoints);
|
|
||||||
const seg = res.routes[0].overview_path.map((ll) => ({
|
|
||||||
lat: ll.lat(),
|
|
||||||
lng: ll.lng()
|
|
||||||
}));
|
|
||||||
// Avoid duplicating the join point between adjacent chunks.
|
|
||||||
if (all.length > 0 && seg.length > 0) seg.shift();
|
|
||||||
all.push(...seg);
|
|
||||||
i += take;
|
|
||||||
}
|
|
||||||
if (!cancelled) setRoutePath(all);
|
|
||||||
} catch {
|
|
||||||
// Fall back to the straight-line skeleton on failure (quota, no route, etc.).
|
|
||||||
if (!cancelled) setRoutePath([]);
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setRouteLoading(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [isLoaded, dropPath]);
|
|
||||||
|
|
||||||
// Numbered step icon as a data URL — drawn fresh per render so we can pass
|
|
||||||
// the step number into the SVG without juggling external assets. Color is
|
|
||||||
// brand purple to match the planned-route polyline below.
|
|
||||||
const stepIcon = (n, isFocused) => {
|
|
||||||
const size = isFocused ? 38 : 32;
|
|
||||||
const color = isFocused ? '#900E1D' : '#C01227';
|
|
||||||
const svg = encodeURIComponent(
|
|
||||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="${size}" height="${size}">` +
|
|
||||||
`<circle cx="16" cy="16" r="14" fill="${color}" stroke="white" stroke-width="3"/>` +
|
|
||||||
`<text x="16" y="21" text-anchor="middle" font-family="Arial,sans-serif" font-size="14" font-weight="700" fill="white">${n}</text>` +
|
|
||||||
`</svg>`
|
|
||||||
);
|
|
||||||
return `data:image/svg+xml;charset=UTF-8,${svg}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const headerBar = (
|
|
||||||
<Stack
|
|
||||||
direction="row"
|
|
||||||
alignItems="center"
|
|
||||||
spacing={1.5}
|
|
||||||
sx={{
|
|
||||||
px: 2,
|
|
||||||
py: 1.25,
|
|
||||||
borderBottom: '1px solid rgba(15, 23, 42, 0.08)',
|
|
||||||
background: 'linear-gradient(135deg, #C01227 0%, #D35968 100%)',
|
|
||||||
color: '#fff',
|
|
||||||
flexShrink: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdRoute size={20} />
|
|
||||||
<Stack sx={{ flex: 1, minWidth: 0 }}>
|
|
||||||
<Typography sx={{ fontWeight: 700, fontSize: 15, lineHeight: 1.2 }}>
|
|
||||||
Planned route{riderName ? ` — ${riderName}` : ''}
|
|
||||||
</Typography>
|
|
||||||
{dateRange && (
|
|
||||||
<Typography sx={{ fontSize: 12, opacity: 0.85 }}>{dateRange}</Typography>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
{details && details.length > 0 && (
|
|
||||||
<Typography sx={{ fontSize: 12, opacity: 0.9, fontWeight: 600 }}>
|
|
||||||
{details.length} {details.length === 1 ? 'stop' : 'stops'}
|
|
||||||
{routeLoading ? ' · resolving route…' : ''}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{onClose && (
|
|
||||||
<IconButton size="small" onClick={onClose} sx={{ color: '#fff' }} aria-label="Close">
|
|
||||||
<MdClose />
|
|
||||||
</IconButton>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Loading state — route fetch in flight OR Google Maps script not ready yet.
|
|
||||||
if (loading || !isLoaded) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
{headerBar}
|
|
||||||
<Stack alignItems="center" justifyContent="center" sx={{ flex: 1, gap: 1.5 }}>
|
|
||||||
<CircularProgress size={32} />
|
|
||||||
<Typography sx={{ color: '#64748b', fontSize: 13 }}>
|
|
||||||
{loading ? 'Loading planned route…' : 'Loading map…'}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Empty state — fetched but rider has no deliveries with drop coords in the
|
|
||||||
// selected window.
|
|
||||||
if (!details || details.length === 0) {
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
{headerBar}
|
|
||||||
<Stack alignItems="center" justifyContent="center" sx={{ flex: 1, gap: 1, p: 3 }}>
|
|
||||||
<Typography sx={{ color: '#1e293b', fontWeight: 700, fontSize: 16 }}>
|
|
||||||
No planned route for this rider
|
|
||||||
</Typography>
|
|
||||||
<Typography sx={{ color: '#64748b', fontSize: 13, textAlign: 'center', maxWidth: 360 }}>
|
|
||||||
There are no deliveries with drop coordinates assigned to this rider for the selected date range.
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
|
||||||
{headerBar}
|
|
||||||
<Box sx={{ flex: 1, minHeight: 0 }}>
|
|
||||||
<GoogleMap
|
|
||||||
mapContainerStyle={containerStyle}
|
|
||||||
onLoad={(map) => (mapRef.current = map)}
|
|
||||||
center={dropPath[0]}
|
|
||||||
zoom={14}
|
|
||||||
options={{
|
|
||||||
streetViewControl: false,
|
|
||||||
mapTypeControl: false,
|
|
||||||
fullscreenControl: false
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{routePath.length > 0 ? (
|
|
||||||
<>
|
|
||||||
{/* Translucent backdrop so the route stays legible on busy tiles. */}
|
|
||||||
<Polyline
|
|
||||||
path={routePath}
|
|
||||||
options={{ strokeColor: '#C01227', strokeOpacity: 0.25, strokeWeight: 8 }}
|
|
||||||
/>
|
|
||||||
{/* Road-following planned route from the Directions API. */}
|
|
||||||
<Polyline
|
|
||||||
path={routePath}
|
|
||||||
options={{ strokeColor: '#C01227', strokeOpacity: 0.95, strokeWeight: 4 }}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
// Fallback while Directions is in flight (or if it fails) — dashed
|
|
||||||
// straight-line skeleton between drop pins in step order.
|
|
||||||
<Polyline
|
|
||||||
path={dropPath}
|
|
||||||
options={{
|
|
||||||
strokeColor: '#C01227',
|
|
||||||
strokeOpacity: 0,
|
|
||||||
strokeWeight: 0,
|
|
||||||
icons: [
|
|
||||||
{
|
|
||||||
icon: {
|
|
||||||
path: 'M 0,-1 0,1',
|
|
||||||
strokeOpacity: 0.6,
|
|
||||||
strokeColor: '#C01227',
|
|
||||||
scale: 3
|
|
||||||
},
|
|
||||||
offset: '0',
|
|
||||||
repeat: '14px'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{details.map((d, i) => {
|
|
||||||
const stepNum = d.step || i + 1;
|
|
||||||
const isFocused = focusedStep === d.deliveryid;
|
|
||||||
return (
|
|
||||||
<Marker
|
|
||||||
key={`step-${d.deliveryid || d.orderid || i}`}
|
|
||||||
position={{ lat: d.dropLat, lng: d.dropLng }}
|
|
||||||
icon={{ url: stepIcon(stepNum, isFocused) }}
|
|
||||||
onClick={() => setFocusedStep(isFocused ? null : d.deliveryid)}
|
|
||||||
zIndex={isFocused ? 1000 : stepNum}
|
|
||||||
>
|
|
||||||
{isFocused && (
|
|
||||||
<InfoWindow onCloseClick={() => setFocusedStep(null)}>
|
|
||||||
<Box sx={{ minWidth: 180, fontFamily: 'inherit' }}>
|
|
||||||
<Typography sx={{ fontWeight: 800, fontSize: 13, color: '#0f172a' }}>
|
|
||||||
Step {stepNum} · {d.customer}
|
|
||||||
</Typography>
|
|
||||||
{d.address && (
|
|
||||||
<Typography sx={{ fontSize: 12, color: '#475569', mt: 0.5 }}>
|
|
||||||
{d.address}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{d.expectedTime && (
|
|
||||||
<Typography sx={{ fontSize: 12, color: '#64748b', mt: 0.5 }}>
|
|
||||||
ETA {String(d.expectedTime).slice(11, 16) || d.expectedTime}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
{d.orderid && (
|
|
||||||
<Typography sx={{ fontSize: 11, color: '#94a3b8', mt: 0.5 }}>
|
|
||||||
Order #{d.orderid}
|
|
||||||
</Typography>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</InfoWindow>
|
|
||||||
)}
|
|
||||||
</Marker>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</GoogleMap>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import { MapContainer, TileLayer, Marker, Polyline, Tooltip } from 'react-leaflet';
|
|
||||||
import L from 'leaflet';
|
|
||||||
import 'leaflet/dist/leaflet.css';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
import { Chip, Stack, Typography, Box } from '@mui/material';
|
|
||||||
import { CloseCircleOutlined } from '@ant-design/icons';
|
|
||||||
import { useTheme } from '@mui/material/styles';
|
|
||||||
import CircularLoader from 'components/CircularLoader';
|
|
||||||
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
|
||||||
dayjs.extend(utc);
|
|
||||||
|
|
||||||
// Start marker
|
|
||||||
const startIcon = new L.Icon({
|
|
||||||
iconUrl: 'https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
|
|
||||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
|
||||||
iconSize: [25, 41],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
shadowSize: [41, 41]
|
|
||||||
});
|
|
||||||
|
|
||||||
// End marker
|
|
||||||
const endIcon = new L.Icon({
|
|
||||||
iconUrl: 'https://cdn.rawgit.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
|
|
||||||
shadowUrl: 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/0.7.7/images/marker-shadow.png',
|
|
||||||
iconSize: [25, 41],
|
|
||||||
iconAnchor: [12, 41],
|
|
||||||
shadowSize: [41, 41]
|
|
||||||
});
|
|
||||||
|
|
||||||
const MapWithRoute = ({ coordinates, additionalProps, order, setMapOpen }) => {
|
|
||||||
console.log('additionalProps', additionalProps);
|
|
||||||
|
|
||||||
const mapRef = useRef(null);
|
|
||||||
const theme = useTheme();
|
|
||||||
const [routePoints, setRoutePoints] = useState([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
// Fit the map to bounds
|
|
||||||
useEffect(() => {
|
|
||||||
if (mapRef.current && coordinates.length > 0) {
|
|
||||||
const bounds = [
|
|
||||||
[Math.min(...coordinates.map((c) => c.lat)), Math.min(...coordinates.map((c) => c.lng))],
|
|
||||||
[Math.max(...coordinates.map((c) => c.lat)), Math.max(...coordinates.map((c) => c.lng))]
|
|
||||||
];
|
|
||||||
mapRef.current.fitBounds(bounds);
|
|
||||||
}
|
|
||||||
}, [coordinates]);
|
|
||||||
|
|
||||||
// Fetch OSRM Route → REAL ROAD ROUTE
|
|
||||||
useEffect(() => {
|
|
||||||
const getOSRMRoute = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
// FIX: If only one coordinate, stop loader and exit
|
|
||||||
if (coordinates.length < 2) {
|
|
||||||
setRoutePoints([]); // no route
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const subsample = (arr, max) => {
|
|
||||||
if (arr.length <= max) return arr;
|
|
||||||
const step = Math.ceil(arr.length / max);
|
|
||||||
const out = arr.filter((_, i) => i % step === 0);
|
|
||||||
const last = arr[arr.length - 1];
|
|
||||||
if (out[out.length - 1] !== last) out.push(last);
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Attempt 1 — map-matching (best fidelity for dense traces).
|
|
||||||
try {
|
|
||||||
const ptsM = subsample(coordinates, 90);
|
|
||||||
const coordsM = ptsM.map((p) => `${p.lng},${p.lat}`).join(';');
|
|
||||||
const matchUrl = `https://router.project-osrm.org/match/v1/driving/${coordsM}?overview=full&geometries=geojson&gaps=ignore&tidy=true`;
|
|
||||||
|
|
||||||
const resM = await fetch(matchUrl);
|
|
||||||
const jsonM = await resM.json();
|
|
||||||
if (jsonM.matchings && jsonM.matchings.length > 0) {
|
|
||||||
const poly = jsonM.matchings.flatMap((m) =>
|
|
||||||
(m.geometry?.coordinates || []).map(([lng, lat]) => ({ lat, lng }))
|
|
||||||
);
|
|
||||||
if (poly.length >= 2) {
|
|
||||||
setRoutePoints(poly);
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('OSRM Match error, trying route fallback:', e);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attempt 2 — waypoint routing through a coarser subsample.
|
|
||||||
try {
|
|
||||||
const ptsR = subsample(coordinates, 25);
|
|
||||||
const coordsR = ptsR.map((p) => `${p.lng},${p.lat}`).join(';');
|
|
||||||
const routeUrl = `https://router.project-osrm.org/route/v1/driving/${coordsR}?overview=full&geometries=geojson`;
|
|
||||||
|
|
||||||
const resR = await fetch(routeUrl);
|
|
||||||
const jsonR = await resR.json();
|
|
||||||
if (jsonR.routes && jsonR.routes[0]) {
|
|
||||||
const poly = jsonR.routes[0].geometry.coordinates.map(([lng, lat]) => ({ lat, lng }));
|
|
||||||
setRoutePoints(poly);
|
|
||||||
} else {
|
|
||||||
// Fallback to drawing direct lines between coordinates
|
|
||||||
setRoutePoints(coordinates);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('OSRM Route fallback error:', err);
|
|
||||||
setRoutePoints(coordinates);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
getOSRMRoute();
|
|
||||||
}, [coordinates]);
|
|
||||||
|
|
||||||
if (!coordinates || coordinates.length === 0) return null;
|
|
||||||
|
|
||||||
const start = coordinates[0];
|
|
||||||
const end = coordinates[coordinates.length - 1];
|
|
||||||
const center = coordinates[Math.floor(coordinates.length / 2)];
|
|
||||||
|
|
||||||
const InfoItem = ({ label, value }) => (
|
|
||||||
<Stack direction="row" spacing={1} alignItems="center">
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
|
||||||
{label}:
|
|
||||||
</Typography>
|
|
||||||
<Chip label={value || '0.00'} color="primary" sx={{ fontWeight: 700 }} />
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box sx={{ width: '100%', height: '100vh', position: 'relative', overflow: 'hidden' }}>
|
|
||||||
{loading && <CircularLoader />}
|
|
||||||
{/* CLOSE BUTTON */}
|
|
||||||
<Chip
|
|
||||||
label="Close"
|
|
||||||
icon={<CloseCircleOutlined style={{ fontSize: 18 }} />}
|
|
||||||
onClick={() => setMapOpen(false)}
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 12,
|
|
||||||
right: 12,
|
|
||||||
zIndex: 2000,
|
|
||||||
bgcolor: theme.palette.error.main,
|
|
||||||
color: '#fff',
|
|
||||||
fontWeight: 600,
|
|
||||||
borderRadius: '12px',
|
|
||||||
px: 1.5,
|
|
||||||
py: 0.5,
|
|
||||||
boxShadow: theme.shadows[4],
|
|
||||||
cursor: 'pointer',
|
|
||||||
'& .MuiChip-icon': { color: '#fff' }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* MAP */}
|
|
||||||
<MapContainer center={center} zoom={14} scrollWheelZoom style={{ height: '100%', width: '100%' }} ref={mapRef}>
|
|
||||||
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
|
|
||||||
|
|
||||||
{/* START MARKER */}
|
|
||||||
<Marker position={start} icon={startIcon}>
|
|
||||||
<Tooltip direction="bottom">{`Pickup: ${dayjs(additionalProps.riderStart).format('DD-MM-YYYY hh:mm A')}`}</Tooltip>
|
|
||||||
</Marker>
|
|
||||||
|
|
||||||
{/* END MARKER */}
|
|
||||||
<Marker position={end} icon={endIcon}>
|
|
||||||
<Tooltip direction="bottom">{`Drop: ${dayjs(additionalProps.riderEnd).format('DD-MM-YYYY hh:mm A')}`}</Tooltip>
|
|
||||||
</Marker>
|
|
||||||
|
|
||||||
{/* REAL OSRM ROUTE */}
|
|
||||||
{routePoints.length > 0 && <Polyline positions={routePoints} pathOptions={{ color: 'blue', weight: 5 }} />}
|
|
||||||
</MapContainer>
|
|
||||||
|
|
||||||
{/* BOTTOM DETAILS */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
bottom: 0,
|
|
||||||
width: '100%',
|
|
||||||
bgcolor: 'rgba(255,255,255,0.96)',
|
|
||||||
p: 2,
|
|
||||||
boxShadow: theme.shadows[3],
|
|
||||||
zIndex: 1500
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack direction="row" flexWrap="wrap" rowGap={1.5} columnGap={3} alignItems="center">
|
|
||||||
<InfoItem label="Tenant" value={order?.tenantname} />
|
|
||||||
<InfoItem label="Rider" value={order?.ridername} />
|
|
||||||
<InfoItem label="Pickup" value={order?.pickupcustomer} />
|
|
||||||
<InfoItem label="Drop" value={order?.deliverycustomer} />
|
|
||||||
<InfoItem label="Kms" value={order?.kms} />
|
|
||||||
<InfoItem label="Actual Kms" value={order?.actualkms} />
|
|
||||||
<InfoItem label="Rider Kms" value={order?.riderkms} />
|
|
||||||
</Stack>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MapWithRoute;
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,799 +0,0 @@
|
|||||||
import React, { useState, useEffect, useMemo } from 'react';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import { useInfiniteQuery } from '@tanstack/react-query';
|
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
Box,
|
|
||||||
Chip,
|
|
||||||
Grid,
|
|
||||||
Paper,
|
|
||||||
Stack,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableContainer,
|
|
||||||
TableHead,
|
|
||||||
TableRow,
|
|
||||||
Tooltip,
|
|
||||||
Typography,
|
|
||||||
useMediaQuery,
|
|
||||||
useTheme
|
|
||||||
} from '@mui/material';
|
|
||||||
import {
|
|
||||||
MdMyLocation,
|
|
||||||
MdCalendarMonth,
|
|
||||||
MdPerson,
|
|
||||||
MdOutlineLocalShipping,
|
|
||||||
MdOutlineCurrencyRupee,
|
|
||||||
MdStraighten,
|
|
||||||
MdPayments,
|
|
||||||
MdRoute,
|
|
||||||
MdTrendingUp,
|
|
||||||
MdTrendingDown
|
|
||||||
} from 'react-icons/md';
|
|
||||||
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
var utc = require('dayjs/plugin/utc');
|
|
||||||
dayjs.extend(utc);
|
|
||||||
|
|
||||||
import { fetchDeliveries } from 'pages/api/api';
|
|
||||||
import Loader from 'components/Loader';
|
|
||||||
import DateFilterDialog from 'components/DateFilterDialog';
|
|
||||||
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 { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
|
||||||
|
|
||||||
const DT = {
|
|
||||||
radiusPill: 999,
|
|
||||||
radiusCard: 14,
|
|
||||||
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
|
||||||
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
|
||||||
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
|
||||||
textPrimary: '#0f172a',
|
|
||||||
textSecondary: '#64748b',
|
|
||||||
textMuted: '#94a3b8',
|
|
||||||
borderSubtle: '#e2e8f0',
|
|
||||||
divider: '#f1f5f9',
|
|
||||||
surface: '#ffffff',
|
|
||||||
surfaceAlt: '#f8fafc'
|
|
||||||
};
|
|
||||||
|
|
||||||
const aColor = (c, suffix) => `${c}${suffix}`;
|
|
||||||
const soft = (c) => aColor(c, '18');
|
|
||||||
const tint = (c) => aColor(c, '08');
|
|
||||||
const edge = (c) => aColor(c, '55');
|
|
||||||
const ring = (c) => aColor(c, '26');
|
|
||||||
|
|
||||||
const BRAND = '#C01227';
|
|
||||||
|
|
||||||
const SoftPaper = (props) => (
|
|
||||||
<Paper
|
|
||||||
{...props}
|
|
||||||
sx={{
|
|
||||||
mt: 0.75,
|
|
||||||
borderRadius: 2,
|
|
||||||
boxShadow: DT.shadowPop,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: 'divider',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
SoftPaper.propTypes = {
|
|
||||||
children: PropTypes.node
|
|
||||||
};
|
|
||||||
|
|
||||||
const AccentAvatar = ({ color, selected, size = 24, children }) => (
|
|
||||||
<Avatar
|
|
||||||
sx={{
|
|
||||||
width: size,
|
|
||||||
height: size,
|
|
||||||
bgcolor: selected ? color : soft(color),
|
|
||||||
color: selected ? '#fff' : color,
|
|
||||||
transition: 'background-color 0.15s, color 0.15s'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Avatar>
|
|
||||||
);
|
|
||||||
|
|
||||||
AccentAvatar.propTypes = {
|
|
||||||
color: PropTypes.string.isRequired,
|
|
||||||
selected: PropTypes.bool,
|
|
||||||
size: PropTypes.number,
|
|
||||||
children: PropTypes.node
|
|
||||||
};
|
|
||||||
|
|
||||||
const MetricPill = ({ color, icon, label, tooltip, minWidth = 80 }) => (
|
|
||||||
<Tooltip title={tooltip || ''} placement="top">
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.5,
|
|
||||||
px: 1,
|
|
||||||
py: 0.375,
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: '#ffffff',
|
|
||||||
border: `1px solid ${edge(color)}`,
|
|
||||||
color,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
minWidth,
|
|
||||||
justifyContent: 'center',
|
|
||||||
whiteSpace: 'nowrap'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
|
|
||||||
MetricPill.propTypes = {
|
|
||||||
color: PropTypes.string.isRequired,
|
|
||||||
icon: PropTypes.node,
|
|
||||||
label: PropTypes.string.isRequired,
|
|
||||||
tooltip: PropTypes.string,
|
|
||||||
minWidth: PropTypes.number
|
|
||||||
};
|
|
||||||
|
|
||||||
const BATCHES = [
|
|
||||||
{ id: 'morning', name: 'Morning Batch', startHour: 0, endHour: 8 },
|
|
||||||
{ id: 'afternoon', name: 'Afternoon Batch', startHour: 9, endHour: 12.5 },
|
|
||||||
{ id: 'evening', name: 'Evening Batch', startHour: 16, endHour: 19 }
|
|
||||||
];
|
|
||||||
|
|
||||||
const getBatchForHour = (h, batches = BATCHES) => {
|
|
||||||
for (const b of batches) {
|
|
||||||
if (h >= b.startHour && h < b.endHour) return b.id;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getRowBatch = (r, batches = BATCHES) => {
|
|
||||||
const t = r?.assigntime;
|
|
||||||
if (!t) return null;
|
|
||||||
const str = String(t).trim();
|
|
||||||
if (/^\d{4}-\d{2}-\d{2}$/.test(str)) return null;
|
|
||||||
const d = dayjs(t);
|
|
||||||
if (!d.isValid()) return null;
|
|
||||||
return getBatchForHour(d.hour() + d.minute() / 60, batches);
|
|
||||||
};
|
|
||||||
|
|
||||||
function formatNumberToRupees(value) {
|
|
||||||
return new Intl.NumberFormat('en-IN', {
|
|
||||||
style: 'currency',
|
|
||||||
currency: 'INR',
|
|
||||||
minimumFractionDigits: 2
|
|
||||||
}).format(Number(value) || 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ProfitabilityReport() {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
|
|
||||||
const [startdate, setStartdate] = useState(dayjs().format('YYYY-MM-DD'));
|
|
||||||
const [enddate, setEnddate] = useState(dayjs().format('YYYY-MM-DD'));
|
|
||||||
const [locaName, setLocoName] = useState('All');
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [datestatus, setDatestatus] = useState('Today');
|
|
||||||
const [appId, setAppId] = useState(0);
|
|
||||||
|
|
||||||
const [searchword, setSearchword] = useState('');
|
|
||||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
||||||
|
|
||||||
const liveUserid = typeof window !== 'undefined' ? localStorage.getItem('userid') || 0 : 0;
|
|
||||||
|
|
||||||
// Load slots configuration from localStorage to match Dispatch page edits
|
|
||||||
const customBatches = useMemo(() => {
|
|
||||||
if (typeof window === 'undefined') return BATCHES;
|
|
||||||
try {
|
|
||||||
const raw = window.localStorage.getItem('dispatch.slots.v9');
|
|
||||||
if (!raw) return BATCHES;
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
if (!Array.isArray(parsed) || parsed.length !== BATCHES.length) return BATCHES;
|
|
||||||
return parsed.map((s, i) => {
|
|
||||||
const id = s.id || `slot-${i + 1}`;
|
|
||||||
const startHour = Number(s.startHour) || 0;
|
|
||||||
const endHour = Number(s.endHour) || 24;
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: s.name || BATCHES.find((b) => b.id === id)?.name || `Slot ${i + 1}`,
|
|
||||||
startHour,
|
|
||||||
endHour
|
|
||||||
};
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
return BATCHES;
|
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Fetch all deliveries for the selected date range and zone
|
|
||||||
const {
|
|
||||||
data: deliveriesData,
|
|
||||||
isLoading: isLoadingDeliveries,
|
|
||||||
fetchNextPage,
|
|
||||||
hasNextPage,
|
|
||||||
isFetchingNextPage
|
|
||||||
} = useInfiniteQuery({
|
|
||||||
queryKey: ['fetchdeliveries', appId, liveUserid, 'all', startdate, enddate, 2000, '', 0, 0, 0],
|
|
||||||
queryFn: fetchDeliveries,
|
|
||||||
getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined,
|
|
||||||
refetchOnWindowFocus: false
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-page through all results
|
|
||||||
useEffect(() => {
|
|
||||||
if (hasNextPage && !isFetchingNextPage) {
|
|
||||||
fetchNextPage();
|
|
||||||
}
|
|
||||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
||||||
|
|
||||||
// Flatten and deduplicate deliveries by orderid
|
|
||||||
const liveRows = useMemo(() => {
|
|
||||||
const all = (deliveriesData?.pages || []).flatMap((p) => p.rows || []);
|
|
||||||
const seen = new Set();
|
|
||||||
const out = [];
|
|
||||||
for (const r of all) {
|
|
||||||
const key = r.orderid != null ? String(r.orderid) : null;
|
|
||||||
if (key && seen.has(key)) continue;
|
|
||||||
if (key) seen.add(key);
|
|
||||||
out.push(r);
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}, [deliveriesData]);
|
|
||||||
|
|
||||||
// Group deliveries by rider
|
|
||||||
const ridersList = useMemo(() => {
|
|
||||||
const riderMap = {};
|
|
||||||
liveRows.forEach((r) => {
|
|
||||||
const key = String(r.userid || r.rider_id || '');
|
|
||||||
if (!key || key === 'unassigned' || key === '0') return;
|
|
||||||
if (!riderMap[key]) {
|
|
||||||
riderMap[key] = {
|
|
||||||
id: key,
|
|
||||||
riderName: r.ridername || r.rider_name || r.username || `Rider ${key}`,
|
|
||||||
orders: []
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (!riderMap[key].orders.some((existing) => existing.orderid === r.orderid)) {
|
|
||||||
riderMap[key].orders.push(r);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return Object.values(riderMap)
|
|
||||||
.map((r) => ({
|
|
||||||
...r,
|
|
||||||
orders: [...r.orders].sort((a, b) => {
|
|
||||||
const tA = a.trip_number || 1;
|
|
||||||
const tB = b.trip_number || 1;
|
|
||||||
if (tA !== tB) return tA - tB;
|
|
||||||
return (a.step || 0) - (b.step || 0);
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
.sort((a, b) => b.orders.length - a.orders.length);
|
|
||||||
}, [liveRows]);
|
|
||||||
|
|
||||||
// Calculate profitability metrics for all riders
|
|
||||||
const stats = useMemo(() => {
|
|
||||||
let activeRiders = 0;
|
|
||||||
let totalOrders = 0;
|
|
||||||
let totalRevenue = 0;
|
|
||||||
let totalCost = 0;
|
|
||||||
let profitableRiders = 0;
|
|
||||||
let lossRiders = 0;
|
|
||||||
let totalKms = 0;
|
|
||||||
|
|
||||||
const list = ridersList
|
|
||||||
.map((r) => {
|
|
||||||
let rRevenue = 0;
|
|
||||||
let rKms = 0;
|
|
||||||
const slotsByDate = {};
|
|
||||||
let ordersInSlots = 0;
|
|
||||||
|
|
||||||
r.orders.forEach((o) => {
|
|
||||||
const slot = getRowBatch(o, customBatches);
|
|
||||||
if (!slot) return;
|
|
||||||
|
|
||||||
const oKms = parseFloat(o.riderkms || 0);
|
|
||||||
rKms += oKms;
|
|
||||||
rRevenue += oKms <= 8 ? 30 : 30 + (oKms - 8) * 6;
|
|
||||||
|
|
||||||
const dateStr = o.assigntime
|
|
||||||
? dayjs(o.assigntime).format('YYYY-MM-DD')
|
|
||||||
: o.deliverydate
|
|
||||||
? dayjs(o.deliverydate).format('YYYY-MM-DD')
|
|
||||||
: null;
|
|
||||||
if (!dateStr) return;
|
|
||||||
|
|
||||||
if (!slotsByDate[dateStr]) {
|
|
||||||
slotsByDate[dateStr] = new Set();
|
|
||||||
}
|
|
||||||
slotsByDate[dateStr].add(slot);
|
|
||||||
ordersInSlots++;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (ordersInSlots === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sum unique slots per day, capping at 3 slots max per day
|
|
||||||
let slotCount = 0;
|
|
||||||
Object.values(slotsByDate).forEach((set) => {
|
|
||||||
slotCount += Math.min(set.size, 3);
|
|
||||||
});
|
|
||||||
|
|
||||||
const rVarCost = rKms * 2.5;
|
|
||||||
const rFixedCost = slotCount * (500 / 3);
|
|
||||||
const rTotalCost = rVarCost + rFixedCost;
|
|
||||||
const rNet = rRevenue - rTotalCost;
|
|
||||||
const rMargin = rRevenue > 0 ? (rNet / rRevenue) * 100 : 0;
|
|
||||||
|
|
||||||
if (rNet >= 0) {
|
|
||||||
profitableRiders++;
|
|
||||||
} else {
|
|
||||||
lossRiders++;
|
|
||||||
}
|
|
||||||
|
|
||||||
totalOrders += ordersInSlots;
|
|
||||||
totalRevenue += rRevenue;
|
|
||||||
totalCost += rTotalCost;
|
|
||||||
totalKms += rKms;
|
|
||||||
activeRiders++;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...r,
|
|
||||||
kms: rKms,
|
|
||||||
revenue: rRevenue,
|
|
||||||
varCost: rVarCost,
|
|
||||||
fixedCost: rFixedCost,
|
|
||||||
totalCost: rTotalCost,
|
|
||||||
net: rNet,
|
|
||||||
margin: rMargin
|
|
||||||
};
|
|
||||||
})
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
const totalNet = totalRevenue - totalCost;
|
|
||||||
const totalMargin = totalRevenue > 0 ? (totalNet / totalRevenue) * 100 : 0;
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeRiders,
|
|
||||||
totalOrders,
|
|
||||||
totalRevenue,
|
|
||||||
totalCost,
|
|
||||||
totalNet,
|
|
||||||
totalMargin,
|
|
||||||
profitableRiders,
|
|
||||||
lossRiders,
|
|
||||||
totalKms,
|
|
||||||
enrichedRiders: list
|
|
||||||
};
|
|
||||||
}, [ridersList, customBatches]);
|
|
||||||
|
|
||||||
// Filter riders by search query
|
|
||||||
const filteredRiders = useMemo(() => {
|
|
||||||
if (!stats?.enrichedRiders || !Array.isArray(stats.enrichedRiders)) return [];
|
|
||||||
const baseList = stats.enrichedRiders.filter(Boolean);
|
|
||||||
if (!debouncedSearch) return baseList;
|
|
||||||
const q = debouncedSearch.toLowerCase().trim();
|
|
||||||
return baseList.filter(
|
|
||||||
(r) => r && [r.riderName, String(r.id)].filter(Boolean).some((field) => String(field).toLowerCase().includes(q))
|
|
||||||
);
|
|
||||||
}, [stats?.enrichedRiders, debouncedSearch]);
|
|
||||||
|
|
||||||
const KPI_META = [
|
|
||||||
{
|
|
||||||
key: 'riders',
|
|
||||||
label: 'Riders Active',
|
|
||||||
color: BRAND,
|
|
||||||
icon: MdPerson,
|
|
||||||
value: stats?.activeRiders ?? 0,
|
|
||||||
detail: `${stats?.profitableRiders ?? 0} in profit · ${stats?.lossRiders ?? 0} at loss`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'kms',
|
|
||||||
label: 'Total Distance',
|
|
||||||
color: '#10b981',
|
|
||||||
icon: MdStraighten,
|
|
||||||
value: `${(stats?.totalKms ?? 0).toFixed(1)} km`,
|
|
||||||
detail: 'Cumulative travel distance'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'revenue',
|
|
||||||
label: 'Slot Revenue',
|
|
||||||
color: '#0ea5e9',
|
|
||||||
icon: MdOutlineLocalShipping,
|
|
||||||
value: formatNumberToRupees(stats?.totalRevenue ?? 0),
|
|
||||||
detail: `From ${stats?.totalOrders ?? 0} orders`
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'cost',
|
|
||||||
label: 'Slot Cost',
|
|
||||||
color: '#f59e0b',
|
|
||||||
icon: MdPayments,
|
|
||||||
value: formatNumberToRupees(stats?.totalCost ?? 0),
|
|
||||||
detail: 'Fixed + variable'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'net',
|
|
||||||
label: 'Slot Net',
|
|
||||||
color: (stats?.totalNet ?? 0) >= 0 ? '#10b981' : '#ef4444',
|
|
||||||
icon: (stats?.totalNet ?? 0) >= 0 ? MdTrendingUp : MdTrendingDown,
|
|
||||||
value: `${(stats?.totalNet ?? 0) >= 0 ? '+' : ''}${formatNumberToRupees(stats?.totalNet ?? 0)}`,
|
|
||||||
detail: `${(stats?.totalRevenue ?? 0) > 0 ? ((stats?.totalNet ?? 0) / (stats?.totalRevenue ?? 1) >= 0 ? '+' : '') : ''}${(
|
|
||||||
stats?.totalMargin ?? 0
|
|
||||||
).toFixed(0)}% margin`
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{(isLoadingDeliveries || isFetchingNextPage) && <Loader />}
|
|
||||||
|
|
||||||
{/* Page Header */}
|
|
||||||
<PageHeader
|
|
||||||
title="Profitability Report"
|
|
||||||
subtitle={`Live · ${locaName || 'All Zones'} · ${datestatus}`}
|
|
||||||
live
|
|
||||||
action={
|
|
||||||
<LocationAutocomplete
|
|
||||||
locaName={locaName}
|
|
||||||
setAppId={setAppId}
|
|
||||||
setLocoName={setLocoName}
|
|
||||||
pill
|
|
||||||
accentColor={BRAND}
|
|
||||||
icon={<MdMyLocation size={14} />}
|
|
||||||
placeholder="Select Zone"
|
|
||||||
paperComponent={SoftPaper}
|
|
||||||
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* KPI Cards Grid */}
|
|
||||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
|
||||||
{KPI_META.map((item) => {
|
|
||||||
const Icon = item.icon;
|
|
||||||
return (
|
|
||||||
<Grid item key={item.key} xs={6} sm={4} md={2.4}>
|
|
||||||
<StatCard
|
|
||||||
title={item.label}
|
|
||||||
value={item.value ?? 0}
|
|
||||||
icon={<Icon size={20} />}
|
|
||||||
color={item.color}
|
|
||||||
loading={isLoadingDeliveries}
|
|
||||||
/>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary, display: 'block', mt: 0.5, px: 2, fontWeight: 500 }}>
|
|
||||||
{item.detail}
|
|
||||||
</Typography>
|
|
||||||
</Grid>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Grid>
|
|
||||||
|
|
||||||
{/* Filter Bar (date + search) */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
mt: { xs: 1.5, md: 2 },
|
|
||||||
p: { xs: 1, md: 1.5 },
|
|
||||||
borderTopLeftRadius: DT.radiusCard / 8,
|
|
||||||
borderTopRightRadius: DT.radiusCard / 8,
|
|
||||||
borderBottomLeftRadius: 0,
|
|
||||||
borderBottomRightRadius: 0,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: DT.borderSubtle,
|
|
||||||
borderBottom: 0,
|
|
||||||
background: '#fff'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Stack
|
|
||||||
direction={{ xs: 'column', sm: 'row' }}
|
|
||||||
alignItems={{ xs: 'stretch', sm: 'center' }}
|
|
||||||
justifyContent="space-between"
|
|
||||||
spacing={1.25}
|
|
||||||
>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1.25} flexWrap="wrap">
|
|
||||||
<AccentAvatar color={BRAND} size={32}>
|
|
||||||
<MdPerson size={18} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<Stack>
|
|
||||||
<Typography
|
|
||||||
variant="caption"
|
|
||||||
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
|
|
||||||
>
|
|
||||||
Profitability Overview · {datestatus}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
|
||||||
{filteredRiders.length} riders · {stats.profitableRiders} profitable · {stats.lossRiders} at loss
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
<Tooltip title="Date Filter" placement="top">
|
|
||||||
<Box
|
|
||||||
onClick={() => setOpen(true)}
|
|
||||||
sx={{
|
|
||||||
display: 'inline-flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: 0.75,
|
|
||||||
px: 1.25,
|
|
||||||
py: 0.75,
|
|
||||||
borderRadius: 999,
|
|
||||||
cursor: 'pointer',
|
|
||||||
bgcolor: tint('#f59e0b'),
|
|
||||||
border: `1.5px solid ${edge('#f59e0b')}`,
|
|
||||||
color: '#f59e0b',
|
|
||||||
fontWeight: 800,
|
|
||||||
fontSize: 12,
|
|
||||||
ml: 1,
|
|
||||||
transition: 'all 0.18s',
|
|
||||||
'&:hover': { borderColor: '#f59e0b', boxShadow: `0 0 0 3px ${ring('#f59e0b')}` }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<MdCalendarMonth size={14} />
|
|
||||||
{dayjs(startdate).format('DD/MM/YY')} – {dayjs(enddate).format('DD/MM/YY')}
|
|
||||||
</Box>
|
|
||||||
</Tooltip>
|
|
||||||
</Stack>
|
|
||||||
<Box sx={{ width: { xs: '100%', sm: 280, lg: 340 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
|
|
||||||
<DebounceSearchBar
|
|
||||||
value={searchword}
|
|
||||||
onChange={setSearchword}
|
|
||||||
onDebouncedChange={setDebouncedSearch}
|
|
||||||
placeholder="Search riders"
|
|
||||||
sx={{
|
|
||||||
m: 0,
|
|
||||||
width: '100%',
|
|
||||||
borderRadius: 999,
|
|
||||||
bgcolor: '#ffffff',
|
|
||||||
'& fieldset': { borderColor: '#e2e8f0', borderWidth: 1 },
|
|
||||||
'&:hover fieldset': { borderColor: '#cbd5e1' },
|
|
||||||
'&.Mui-focused fieldset': { borderColor: '#C01227', borderWidth: 1.5 },
|
|
||||||
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Table & Mobile List Container */}
|
|
||||||
<Paper
|
|
||||||
elevation={0}
|
|
||||||
sx={{
|
|
||||||
borderTopLeftRadius: 0,
|
|
||||||
borderTopRightRadius: 0,
|
|
||||||
borderBottomLeftRadius: DT.radiusCard / 8,
|
|
||||||
borderBottomRightRadius: DT.radiusCard / 8,
|
|
||||||
border: '1px solid',
|
|
||||||
borderColor: DT.borderSubtle,
|
|
||||||
overflow: 'hidden',
|
|
||||||
background: '#fff'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
<MobileCardList scroll>
|
|
||||||
{!filteredRiders || filteredRiders.length === 0 ? (
|
|
||||||
<Stack alignItems="center" spacing={1.5} sx={{ py: 6 }}>
|
|
||||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
|
||||||
<MdPerson size={28} />
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
||||||
No riders to show
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
) : (
|
|
||||||
filteredRiders.map((row, index) => {
|
|
||||||
if (!row) return null;
|
|
||||||
const isProfit = (row.net ?? 0) >= 0;
|
|
||||||
return (
|
|
||||||
<MobileCard
|
|
||||||
key={row.id || index}
|
|
||||||
accent={isProfit ? '#10b981' : '#ef4444'}
|
|
||||||
header={
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
|
||||||
<AccentAvatar color={isProfit ? '#10b981' : '#ef4444'} size={36}>
|
|
||||||
<MdPerson size={18} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<Stack>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
||||||
{row.riderName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
||||||
ID #{row.id}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MobileFieldGrid columns={2}>
|
|
||||||
<MobileField label="Orders" value={row.orders.length} />
|
|
||||||
<MobileField label="Rider KMs" value={`${row.kms.toFixed(2)} km`} />
|
|
||||||
<MobileField label="Revenue" value={formatNumberToRupees(row.revenue)} />
|
|
||||||
<MobileField label="Fixed Cost" value={formatNumberToRupees(row.fixedCost)} />
|
|
||||||
<MobileField label="Variable Cost" value={formatNumberToRupees(row.varCost)} />
|
|
||||||
<MobileField label="Total Cost" value={formatNumberToRupees(row.totalCost)} />
|
|
||||||
<MobileField label="Net Profit" value={`${isProfit ? '+' : ''}${formatNumberToRupees(row.net)}`} full />
|
|
||||||
<MobileField label="Margin" value={`${Math.abs(row.margin).toFixed(0)}%`} full />
|
|
||||||
</MobileFieldGrid>
|
|
||||||
</MobileCard>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</MobileCardList>
|
|
||||||
) : (
|
|
||||||
<TableContainer
|
|
||||||
sx={{
|
|
||||||
maxHeight: 'calc(100vh - 280px)',
|
|
||||||
'&::-webkit-scrollbar': { width: 10, height: 10 },
|
|
||||||
'&::-webkit-scrollbar-thumb': {
|
|
||||||
backgroundColor: edge(BRAND),
|
|
||||||
borderRadius: 8,
|
|
||||||
'&:hover': { backgroundColor: BRAND }
|
|
||||||
},
|
|
||||||
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Table stickyHeader sx={{ minWidth: 1000 }}>
|
|
||||||
<TableHead>
|
|
||||||
<TableRow
|
|
||||||
sx={{
|
|
||||||
'& th': {
|
|
||||||
backgroundColor: DT.surfaceAlt,
|
|
||||||
color: DT.textSecondary,
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: 800,
|
|
||||||
letterSpacing: 0.6,
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
whiteSpace: 'nowrap',
|
|
||||||
borderBottom: `1px solid ${DT.borderSubtle}`,
|
|
||||||
py: 1.25,
|
|
||||||
px: 2
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>#</TableCell>
|
|
||||||
<TableCell>Rider</TableCell>
|
|
||||||
<TableCell align="center">Orders</TableCell>
|
|
||||||
<TableCell align="center">Rider KMs</TableCell>
|
|
||||||
<TableCell align="center">Revenue</TableCell>
|
|
||||||
<TableCell align="center">Fixed Cost</TableCell>
|
|
||||||
<TableCell align="center">Variable Cost</TableCell>
|
|
||||||
<TableCell align="center">Total Cost</TableCell>
|
|
||||||
<TableCell align="center">Net Profit</TableCell>
|
|
||||||
<TableCell align="center">Margin</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableHead>
|
|
||||||
<TableBody>
|
|
||||||
{!filteredRiders || filteredRiders.length === 0 ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={10} sx={{ py: 6 }}>
|
|
||||||
<Stack alignItems="center" spacing={1.5}>
|
|
||||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
|
||||||
<MdPerson size={28} />
|
|
||||||
</Avatar>
|
|
||||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
||||||
No riders to show
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
) : (
|
|
||||||
filteredRiders.map((row, index) => {
|
|
||||||
if (!row) return null;
|
|
||||||
const isProfit = (row.net ?? 0) >= 0;
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
key={row.id || index}
|
|
||||||
sx={{
|
|
||||||
transition: 'background-color 0.15s',
|
|
||||||
'& td': {
|
|
||||||
borderBottom: `1px solid ${DT.divider}`,
|
|
||||||
py: 1.5,
|
|
||||||
px: 2
|
|
||||||
},
|
|
||||||
'&:hover': { backgroundColor: DT.surfaceAlt }
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<TableCell>
|
|
||||||
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
|
|
||||||
{String(index + 1).padStart(2, '0')}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Stack direction="row" alignItems="center" spacing={1}>
|
|
||||||
<AccentAvatar color={BRAND} size={36}>
|
|
||||||
<MdPerson size={18} />
|
|
||||||
</AccentAvatar>
|
|
||||||
<Stack>
|
|
||||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}>
|
|
||||||
{row.riderName}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
||||||
ID #{row.id}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
||||||
{row.orders.length}
|
|
||||||
</Typography>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<MetricPill color="#10b981" icon={<MdStraighten size={11} />} label={`${row.kms.toFixed(2)} km`} tooltip="KMS" />
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<MetricPill
|
|
||||||
color={BRAND}
|
|
||||||
icon={<MdOutlineCurrencyRupee size={11} />}
|
|
||||||
label={formatNumberToRupees(row.revenue).replace('₹', '').trim()}
|
|
||||||
tooltip="Revenue"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<MetricPill
|
|
||||||
color="#6366f1"
|
|
||||||
icon={<MdPayments size={11} />}
|
|
||||||
label={formatNumberToRupees(row.fixedCost).replace('₹', '').trim()}
|
|
||||||
tooltip="Fixed Cost"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<MetricPill
|
|
||||||
color="#f59e0b"
|
|
||||||
icon={<MdRoute size={11} />}
|
|
||||||
label={formatNumberToRupees(row.varCost).replace('₹', '').trim()}
|
|
||||||
tooltip="Variable Cost"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<MetricPill
|
|
||||||
color="#94a3b8"
|
|
||||||
icon={<MdPayments size={11} />}
|
|
||||||
label={formatNumberToRupees(row.totalCost).replace('₹', '').trim()}
|
|
||||||
tooltip="Total Cost"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<MetricPill
|
|
||||||
color={isProfit ? '#10b981' : '#ef4444'}
|
|
||||||
icon={isProfit ? <MdTrendingUp size={11} /> : <MdTrendingDown size={11} />}
|
|
||||||
label={`${isProfit ? '+' : ''}${formatNumberToRupees(row.net).replace('₹', '').trim()}`}
|
|
||||||
tooltip="Net Profit"
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="center">
|
|
||||||
<Chip
|
|
||||||
label={`${Math.abs(row.margin).toFixed(0)}%`}
|
|
||||||
color={isProfit ? 'success' : 'error'}
|
|
||||||
size="small"
|
|
||||||
sx={{ fontWeight: 700, minWidth: 60 }}
|
|
||||||
/>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</TableContainer>
|
|
||||||
)}
|
|
||||||
</Paper>
|
|
||||||
|
|
||||||
{/* Date Filter Dialog */}
|
|
||||||
<DateFilterDialog
|
|
||||||
open={open}
|
|
||||||
onClose={() => setOpen(false)}
|
|
||||||
onSelect={(range) => {
|
|
||||||
setStartdate(range.startDate);
|
|
||||||
setEnddate(range.endDate);
|
|
||||||
setDatestatus(range.label);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
import React, { useState, useEffect, Fragment } from 'react';
|
|
||||||
import {
|
|
||||||
Box,
|
|
||||||
Drawer,
|
|
||||||
IconButton,
|
|
||||||
Toolbar,
|
|
||||||
Typography,
|
|
||||||
AppBar,
|
|
||||||
useMediaQuery,
|
|
||||||
Divider,
|
|
||||||
List,
|
|
||||||
ListItem,
|
|
||||||
ListItemText,
|
|
||||||
useTheme,
|
|
||||||
ListItemAvatar,
|
|
||||||
Stack,
|
|
||||||
Button,
|
|
||||||
Checkbox,
|
|
||||||
Skeleton
|
|
||||||
} from '@mui/material';
|
|
||||||
|
|
||||||
import MenuIcon from '@mui/icons-material/Menu';
|
|
||||||
import SearchBar from 'components/nearle_components/SearchBar';
|
|
||||||
import { useQuery } from '@tanstack/react-query';
|
|
||||||
import { fetchRidersLogs } from 'pages/api/api';
|
|
||||||
import RiderLocationMap from './RiderLocationMap';
|
|
||||||
import MainCard from 'components/MainCard';
|
|
||||||
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
|
||||||
import dayjs from 'dayjs';
|
|
||||||
|
|
||||||
import error500 from 'assets/images/maintenance/Error500.png';
|
|
||||||
|
|
||||||
const drawerWidth = 350;
|
|
||||||
|
|
||||||
const RidersLogs = () => {
|
|
||||||
const theme = useTheme();
|
|
||||||
const isDesktop = useMediaQuery('(min-width:900px)');
|
|
||||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
||||||
const [open, setOpen] = useState(false);
|
|
||||||
const [selectedRiders, setSelectedRiders] = useState([]);
|
|
||||||
const [riderSearch, setRiderSearch] = useState('');
|
|
||||||
const appId = 1;
|
|
||||||
const {
|
|
||||||
data: riders,
|
|
||||||
isLoading: ridersIsLoading,
|
|
||||||
isFetching: riderIsFetching,
|
|
||||||
refetch: riderLogsRefetch,
|
|
||||||
error: riderLogsError
|
|
||||||
} = useQuery({
|
|
||||||
queryKey: [appId, dayjs().format('YYYY-MM-DD'), riderSearch],
|
|
||||||
queryFn: fetchRidersLogs,
|
|
||||||
refetchInterval: 5 * 60 * 1000
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// const sortedRiders = riders?.sort((a, b) => a.firstname.localeCompare(b.firstname));
|
|
||||||
setSelectedRiders(riders);
|
|
||||||
}, [riders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
console.log('selectedRiders', selectedRiders);
|
|
||||||
}, [selectedRiders]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOpen(isDesktop);
|
|
||||||
}, [isDesktop]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<MainCard content={false}>
|
|
||||||
<Box sx={{ display: 'flex', width: '100%', height: '100%', position: 'relative' }}>
|
|
||||||
{/* Drawer */}
|
|
||||||
<Drawer
|
|
||||||
variant={isDesktop ? 'persistent' : 'temporary'}
|
|
||||||
open={open}
|
|
||||||
onClose={() => !isDesktop && setOpen(false)}
|
|
||||||
ModalProps={{ keepMounted: true }}
|
|
||||||
sx={{
|
|
||||||
'& .MuiDrawer-paper': {
|
|
||||||
width: isMobile ? '100vw' : drawerWidth,
|
|
||||||
maxWidth: isMobile ? '100vw' : drawerWidth,
|
|
||||||
position: 'absolute',
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
height: '100%',
|
|
||||||
overflowY: 'auto',
|
|
||||||
transition: 'transform 0.35s ease-in-out',
|
|
||||||
zIndex: 13
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Search */}
|
|
||||||
<Box sx={{ position: 'sticky', top: 0, zIndex: 1 }}>
|
|
||||||
<SearchBar
|
|
||||||
value={riderSearch}
|
|
||||||
placeholder="Search Rider"
|
|
||||||
onChange={(e) => setRiderSearch(e.target.value)}
|
|
||||||
sx={{
|
|
||||||
height: 60,
|
|
||||||
bgcolor: 'white',
|
|
||||||
'& .MuiOutlinedInput-notchedOutline': {
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: theme.palette.secondary.light
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<List>
|
|
||||||
<ListItem sx={{ cursor: 'pointer', '&:hover': { bgcolor: theme.palette.secondary.lighter }, bgcolor: 'white', mt: -1 }}>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Checkbox
|
|
||||||
checked={riders?.length == selectedRiders?.length}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedRiders(riders);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemAvatar>
|
|
||||||
<ListItemText primary="All" />
|
|
||||||
</ListItem>
|
|
||||||
<Divider />
|
|
||||||
</List>
|
|
||||||
</Box>
|
|
||||||
{/* Rider List */}
|
|
||||||
<List>
|
|
||||||
{/* Individuals */}
|
|
||||||
{ridersIsLoading || riderIsFetching
|
|
||||||
? Array.from({ length: 10 }).map((_, index) => (
|
|
||||||
<Fragment key={index}>
|
|
||||||
<ListItem sx={{ py: 1.5, px: 2 }}>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Skeleton variant="circular" width={24} height={24} />
|
|
||||||
</ListItemAvatar>
|
|
||||||
|
|
||||||
<ListItemText
|
|
||||||
primary={<Skeleton variant="text" width="60%" height={22} />}
|
|
||||||
secondary={<Skeleton variant="text" width="40%" height={18} />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Stack spacing={0.5} textAlign="right">
|
|
||||||
<Skeleton variant="text" width={50} height={18} />
|
|
||||||
<Skeleton variant="text" width={80} height={16} />
|
|
||||||
</Stack>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
</Fragment>
|
|
||||||
))
|
|
||||||
: !isMobile &&
|
|
||||||
riders?.map((row) => {
|
|
||||||
return (
|
|
||||||
<Fragment key={row.userid}>
|
|
||||||
<ListItem
|
|
||||||
sx={{
|
|
||||||
cursor: 'pointer',
|
|
||||||
py: 1,
|
|
||||||
px: 2,
|
|
||||||
borderRadius: 1,
|
|
||||||
'&:hover': { bgcolor: theme.palette.secondary.lighter }
|
|
||||||
}}
|
|
||||||
secondaryAction={
|
|
||||||
<Stack textAlign="right" spacing={0.5}>
|
|
||||||
<Typography variant="body2" noWrap sx={{ color: row.status == 'active' ? 'success.main' : 'error.main' }}>
|
|
||||||
{row.userid}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>
|
|
||||||
{dayjs(row.logdate).format('DD/MM/YYYY hh:mm A')}
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<ListItemAvatar>
|
|
||||||
<Checkbox
|
|
||||||
sx={{
|
|
||||||
color: row.status == 'active' ? 'green' : 'red',
|
|
||||||
'&.Mui-checked': {
|
|
||||||
color: row.status == 'active' ? 'green' : 'red'
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
checked={
|
|
||||||
// INDIVIDUAL CHECKED CONDITION
|
|
||||||
selectedRiders?.length === 1 && selectedRiders[0]?.userid === row?.userid
|
|
||||||
}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
// SELECT ONE RIDER
|
|
||||||
setSelectedRiders([row]);
|
|
||||||
} else {
|
|
||||||
// UNCHECK -> SELECT ALL
|
|
||||||
setSelectedRiders(riders);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ListItemAvatar>
|
|
||||||
|
|
||||||
<ListItemText
|
|
||||||
primary={
|
|
||||||
<Typography noWrap>
|
|
||||||
{row.username?.slice(0, 25) || ''}
|
|
||||||
{row.username?.length > 25 && '...'}
|
|
||||||
|
|
||||||
{/* {row.status === 'active' && <TaskAltIcon fontSize="small" color="success" sx={{ ml: 1 }} />} */}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
secondary={
|
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>
|
|
||||||
{row.contactno || '##########'}
|
|
||||||
</Typography>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</ListItem>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</List>
|
|
||||||
|
|
||||||
{/* Mobile: rider rows rendered as app-style cards (same selection behaviour) */}
|
|
||||||
{isMobile && !ridersIsLoading && !riderIsFetching && (
|
|
||||||
<MobileCardList>
|
|
||||||
{riders?.map((row) => {
|
|
||||||
const isActive = row.status == 'active';
|
|
||||||
const isSelected = selectedRiders?.length === 1 && selectedRiders[0]?.userid === row?.userid;
|
|
||||||
return (
|
|
||||||
<MobileCard
|
|
||||||
key={row.userid}
|
|
||||||
accent={isActive ? '#10b981' : '#ef4444'}
|
|
||||||
selected={isSelected}
|
|
||||||
header={
|
|
||||||
<Stack direction="row" alignItems="flex-start" spacing={1}>
|
|
||||||
<Checkbox
|
|
||||||
sx={{
|
|
||||||
p: 0.5,
|
|
||||||
color: isActive ? 'green' : 'red',
|
|
||||||
'&.Mui-checked': { color: isActive ? 'green' : 'red' }
|
|
||||||
}}
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={(e) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedRiders([row]);
|
|
||||||
} else {
|
|
||||||
setSelectedRiders(riders);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
|
||||||
<Typography noWrap sx={{ fontWeight: 600 }}>
|
|
||||||
{row.username?.slice(0, 25) || ''}
|
|
||||||
{row.username?.length > 25 && '...'}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary" noWrap>
|
|
||||||
{row.contactno || '##########'}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<MobileFieldGrid>
|
|
||||||
<MobileField label="User ID">
|
|
||||||
<Typography sx={{ fontSize: 13, fontWeight: 600, color: isActive ? 'success.main' : 'error.main' }} noWrap>
|
|
||||||
{row.userid}
|
|
||||||
</Typography>
|
|
||||||
</MobileField>
|
|
||||||
<MobileField label="Status" value={isActive ? 'Active' : 'Inactive'} />
|
|
||||||
<MobileField label="Last Log" value={dayjs(row.logdate).format('DD/MM/YYYY hh:mm A')} full />
|
|
||||||
</MobileFieldGrid>
|
|
||||||
</MobileCard>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</MobileCardList>
|
|
||||||
)}
|
|
||||||
</Drawer>
|
|
||||||
|
|
||||||
{/* AppBar */}
|
|
||||||
<AppBar
|
|
||||||
elevation={0}
|
|
||||||
position="absolute"
|
|
||||||
sx={{
|
|
||||||
top: 0,
|
|
||||||
left: open && isDesktop ? `${drawerWidth}px` : 0,
|
|
||||||
width: open && isDesktop ? `calc(100% - ${drawerWidth}px)` : '100%',
|
|
||||||
transition: 'left 0.3s ease, width 0.3s ease',
|
|
||||||
backgroundColor: 'white',
|
|
||||||
borderBottom: '1px solid',
|
|
||||||
borderColor: theme.palette.secondary.light
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Toolbar>
|
|
||||||
<Stack direction="row" alignItems="center" justifyContent="space-between" sx={{ width: '100%' }}>
|
|
||||||
<Stack direction="row" alignItems="center">
|
|
||||||
<IconButton color="primary" onClick={() => setOpen(!open)}>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
|
||||||
|
|
||||||
<Typography variant="h5" color="primary" sx={{ ml: 2 }}>
|
|
||||||
Riders Locations
|
|
||||||
</Typography>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outlined"
|
|
||||||
color="primary"
|
|
||||||
onClick={() => {
|
|
||||||
riderLogsRefetch();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Refresh
|
|
||||||
</Button>
|
|
||||||
</Stack>
|
|
||||||
</Toolbar>
|
|
||||||
</AppBar>
|
|
||||||
|
|
||||||
{/* Map */}
|
|
||||||
<Box
|
|
||||||
sx={{
|
|
||||||
flexGrow: 1,
|
|
||||||
overflow: 'auto',
|
|
||||||
pt: '64px',
|
|
||||||
pl: open && isDesktop ? `${drawerWidth}px` : 0,
|
|
||||||
transition: 'padding-left 0.3s ease',
|
|
||||||
minHeight: '80vh'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{(ridersIsLoading || riderIsFetching) && (
|
|
||||||
<Box position="relative" width="100%" height="80vh" display="grid" placeItems="center">
|
|
||||||
{/* <CircularLoader /> */}
|
|
||||||
<Skeleton
|
|
||||||
variant="rectangular"
|
|
||||||
width="100%"
|
|
||||||
height="100%"
|
|
||||||
animation="wave"
|
|
||||||
sx={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
borderRadius: 1,
|
|
||||||
zIndex: 1
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{selectedRiders?.length > 0 && <RiderLocationMap riderLocations={selectedRiders} />}
|
|
||||||
{riderLogsError && (
|
|
||||||
<Box sx={{ width: '100% ', height: '100%' }}>
|
|
||||||
<img src={error500} alt="mantis" style={{ height: '100%', width: '100%' }} />
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</MainCard>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default RidersLogs;
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -22,9 +22,6 @@ const OrdersPreview = Loadable(lazy(() => import('pages/nearle/orders/OrdersPrev
|
|||||||
const Deliveries = Loadable(lazy(() => import('pages/nearle/deliveries/deliveries')));
|
const Deliveries = Loadable(lazy(() => import('pages/nearle/deliveries/deliveries')));
|
||||||
const Customers = Loadable(lazy(() => import('pages/nearle/customers/customers')));
|
const Customers = Loadable(lazy(() => import('pages/nearle/customers/customers')));
|
||||||
|
|
||||||
const Invoice = Loadable(lazy(() => import('pages/nearle/invoice/invoice')));
|
|
||||||
const InvoicePreview = Loadable(lazy(() => import('../pages/nearle/invoice/invoicePreview')));
|
|
||||||
|
|
||||||
const Details = Loadable(lazy(() => import('pages/nearle/orders/details')));
|
const Details = Loadable(lazy(() => import('pages/nearle/orders/details')));
|
||||||
|
|
||||||
const ViewProfile = Loadable(lazy(() => import('pages/nearle/viewProfile')));
|
const ViewProfile = Loadable(lazy(() => import('pages/nearle/viewProfile')));
|
||||||
@@ -36,11 +33,6 @@ const Createclient = Loadable(lazy(() => import('pages/nearle/clients/createclie
|
|||||||
const CreateCustomer = Loadable(lazy(() => import('pages/nearle/clients/createCustomer')));
|
const CreateCustomer = Loadable(lazy(() => import('pages/nearle/clients/createCustomer')));
|
||||||
|
|
||||||
const Requests = Loadable(lazy(() => import('pages/nearle/requests/requests')));
|
const Requests = Loadable(lazy(() => import('pages/nearle/requests/requests')));
|
||||||
const OrdersSummary = Loadable(lazy(() => import('pages/nearle/reports/ordersSummary')));
|
|
||||||
const OrdersDetails = Loadable(lazy(() => import('pages/nearle/reports/ordersDetails')));
|
|
||||||
const RidersSummary = Loadable(lazy(() => import('pages/nearle/reports/ridersSummary')));
|
|
||||||
const RidersLogs = Loadable(lazy(() => import('pages/nearle/reports/ridersLogs')));
|
|
||||||
const Profitability = Loadable(lazy(() => import('pages/nearle/reports/profitability')));
|
|
||||||
|
|
||||||
const Riders = Loadable(lazy(() => import('pages/nearle/riders/riders')));
|
const Riders = Loadable(lazy(() => import('pages/nearle/riders/riders')));
|
||||||
const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
|
const Createrider = Loadable(lazy(() => import('pages/nearle/riders/createrider')));
|
||||||
@@ -85,24 +77,6 @@ const MainRoutes = {
|
|||||||
path: 'customers',
|
path: 'customers',
|
||||||
element: <Customers />
|
element: <Customers />
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'invoice',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
index: true,
|
|
||||||
element: <Invoice />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'preview',
|
|
||||||
element: <InvoicePreview />
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'invoice/preview',
|
|
||||||
element: <InvoicePreview />
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'requests',
|
path: 'requests',
|
||||||
element: <Requests />
|
element: <Requests />
|
||||||
@@ -140,31 +114,6 @@ const MainRoutes = {
|
|||||||
path: 'customer/create',
|
path: 'customer/create',
|
||||||
element: <CreateCustomer />
|
element: <CreateCustomer />
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'reports',
|
|
||||||
children: [
|
|
||||||
{
|
|
||||||
path: 'orderssummary',
|
|
||||||
element: <OrdersSummary />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'ordersdetails',
|
|
||||||
element: <OrdersDetails />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'riderssummary',
|
|
||||||
element: <RidersSummary />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'riderslogs',
|
|
||||||
element: <RidersLogs />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'profitability',
|
|
||||||
element: <Profitability />
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'dispatch',
|
path: 'dispatch',
|
||||||
element: <Dispatch />
|
element: <Dispatch />
|
||||||
|
|||||||
@@ -11,13 +11,6 @@
|
|||||||
"pricing": "Pricing",
|
"pricing": "Pricing",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
"riders": "Milers",
|
"riders": "Milers",
|
||||||
"reports": "Reports",
|
|
||||||
"ordersummary": "Orders Summary",
|
|
||||||
"ordersdetails": "Orders Details",
|
|
||||||
"riderssummary": "Riders Summary",
|
|
||||||
"riderslogs": "Riders Logs",
|
|
||||||
"invoice": "Invoice",
|
|
||||||
"dispatch": "Live Operations",
|
"dispatch": "Live Operations",
|
||||||
"profitability": "Profitability",
|
|
||||||
"Doormile": "Doormile"
|
"Doormile": "Doormile"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user