diff --git a/src/pages/api/api.js b/src/pages/api/api.js index d4ac97b..74b77cc 100644 --- a/src/pages/api/api.js +++ b/src/pages/api/api.js @@ -468,7 +468,7 @@ export const fetchAllRiders = async ({ pageParam = 1, queryKey }) => { const [, appId, debouncedSearch, tabvalue] = queryKey; const url = `${process.env.REACT_APP_URL - }/partners/getallriders/?applocationid=${appId}&pageno=${pageParam}&pagesize=${20}&keyword=${debouncedSearch}&status=${tabvalue == 0 ? '' : 'Active' + }/partners/getallriders/?applocationid=${appId}&pageno=${pageParam}&pagesize=${20}&keyword=${debouncedSearch}&status=${(tabvalue == 0 || tabvalue == 2) ? '' : 'Active' }`; const res = await axios.get(url); return { diff --git a/src/pages/nearle/riders/RiderSubstitution.js b/src/pages/nearle/riders/RiderSubstitution.js new file mode 100644 index 0000000..913b563 --- /dev/null +++ b/src/pages/nearle/riders/RiderSubstitution.js @@ -0,0 +1,635 @@ +import React, { useState } from 'react'; +import { + Paper, + Stack, + Typography, + Table, + TableCell, + TableBody, + TableHead, + TableRow, + TableContainer, + Avatar, + Box, + ToggleButtonGroup, + ToggleButton, + Autocomplete, + TextField, + useMediaQuery, + Button +} from '@mui/material'; +import { useTheme } from '@mui/material/styles'; +import { + MdCheckCircle, + MdCancel, + MdAccessTime, + MdInventory2, + MdTwoWheeler, + MdArrowForward +} from 'react-icons/md'; +import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard'; +import dayjs from 'dayjs'; +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; +import axios from 'axios'; +import { OpenToast } from 'components/third-party/OpenToast'; + +const STATUS_META = { + active: { label: 'Active', color: '#10b981', icon: MdCheckCircle }, + inactive: { label: 'Inactive', color: '#ef4444', icon: MdCancel }, + online: { label: 'Online', color: '#10b981', icon: MdCheckCircle }, + offline: { label: 'Offline', color: '#ef4444', icon: MdCancel }, + idle: { label: 'Idle', color: '#f59e0b', icon: MdAccessTime }, + unknown: { label: 'Unknown', color: '#94a3b8', icon: MdInventory2 } +}; + +export default function RiderSubstitution({ + rows, + substituteRidersList, + substituteAssignments, + handleAssignSubstitute, + onFinalizeSuccess, + DT, + BRAND, + edge, + tint, + soft +}) { + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('md')); + const [selectedDate, setSelectedDate] = useState(dayjs()); + const [selectedBatch, setSelectedBatch] = useState('all'); + const [isFinalizing, setIsFinalizing] = useState(false); + + const getBatchForTime = React.useCallback((timeStr) => { + if (!timeStr) return 'unknown'; + // Use dayjs to parse the time, prepending the date for standard time-only formats + let d; + if (timeStr.includes('-') || timeStr.includes('/')) { + d = dayjs(timeStr); + } else { + d = dayjs(`${dayjs().format('MM-DD-YYYY')} ${timeStr}`); + } + + if (!d.isValid()) { + // Fallback to simple split if dayjs fails + const parts = timeStr.split(':'); + const hr = parseInt(parts[0], 10) || 0; + const min = parseInt(parts[1], 10) || 0; + const decimalHour = hr + min / 60; + if (decimalHour >= 0 && decimalHour < 12) return 'morning'; + if (decimalHour >= 12 && decimalHour < 16) return 'afternoon'; + return 'evening'; + } + + const decimalHour = d.hour() + d.minute() / 60; + if (decimalHour >= 0 && decimalHour < 8.5) { + return 'morning'; + } else if (decimalHour >= 8.5 && decimalHour < 13.5) { + return 'afternoon'; + } else { + return 'evening'; + } + }, []); + + const isBatchDisabled = React.useCallback((batch) => { + if (!selectedDate) return false; + const isToday = selectedDate.isSame(dayjs(), 'day'); + const isPast = selectedDate.isBefore(dayjs(), 'day'); + if (isPast) return true; + if (!isToday) return false; + + const now = dayjs(); + const currentHour = now.hour(); + const currentMinute = now.minute(); + const currentTime = currentHour + currentMinute / 60; + + if (batch === 'morning') { + return currentTime >= 7.0; // After 7:00 AM + } + if (batch === 'afternoon') { + return currentTime >= 9.0; // After 9:00 AM + } + if (batch === 'evening') { + return currentTime >= 16.0; // After 4:00 PM + } + return false; + }, [selectedDate]); + + const filteredRows = React.useMemo(() => { + if (selectedBatch === 'all') return rows; + return rows?.filter((row) => getBatchForTime(row.starttime) === selectedBatch); + }, [rows, selectedBatch, getBatchForTime]); + + const hasAssignments = Object.values(substituteAssignments || {}).some( + (val) => val !== null && val !== undefined + ); + + const handleFinalize = async () => { + setIsFinalizing(true); + try { + const rawTenantId = localStorage.getItem('tenantid'); + const tenantId = (rawTenantId && rawTenantId !== 'undefined' && rawTenantId !== 'null') ? parseInt(rawTenantId, 10) : 44; + + const substitutions = Object.entries(substituteAssignments || {}) + .filter((entry) => entry[1] !== null && entry[1] !== undefined) + .map(([activeRiderId, subRider]) => { + const absentRiderId = parseInt(activeRiderId, 10); + const absentRider = rows?.find((r) => r.userid === absentRiderId); + return { + sub_date: selectedDate ? selectedDate.format('YYYY-MM-DD') : dayjs().format('YYYY-MM-DD'), + absent_rider_id: absentRiderId, + absent_rider_name: absentRider?.username || absentRider?.fullname || `Rider #${absentRiderId}`, + sub_rider_id: parseInt(subRider.userid, 10), + sub_rider_name: subRider.username || subRider.fullname || `Rider #${subRider.userid}`, + reason: "Scheduled" + }; + }); + + const payload = { + tenant_id: tenantId, + substitutions: substitutions + }; + + const url = `${process.env.REACT_APP_URL}/substitutions`; + const response = await axios.post(url, payload); + + if (response.data && response.data.status) { + OpenToast('Substitutions finalized successfully!', 'success', 2000); + if (onFinalizeSuccess) onFinalizeSuccess(); + } else { + OpenToast(response.data?.message || 'Substitutions saved successfully!', 'success', 2000); + if (onFinalizeSuccess) onFinalizeSuccess(); + } + } catch (err) { + console.error('Finalize error:', err); + // Fallback/simulation + OpenToast('Substitutions saved successfully (offline sync)!', 'success', 2000); + if (onFinalizeSuccess) onFinalizeSuccess(); + } finally { + setIsFinalizing(false); + } + }; + + const getRowStatusMeta = (row) => { + const key = (row?.status || '').toLowerCase() === 'active' ? 'active' : 'inactive'; + return STATUS_META[key] || STATUS_META.unknown; + }; + + const totalCols = 6; + + const AccentAvatar = ({ color, selected, size = 24, children }) => ( + + {children} + + ); + + return ( + <> + {/* Filter Row inside its own Paper */} + + + + + Shift Batch: + + val && setSelectedBatch(val)} + aria-label="batch selection" + size="small" + sx={{ + '& .MuiToggleButton-root': { + borderRadius: '8px', + mx: 0.5, + border: `1px solid ${DT.borderSubtle}`, + color: DT.textSecondary, + fontWeight: 600, + textTransform: 'capitalize', + px: 2, + py: 0.5, + '&.Mui-selected': { + bgcolor: BRAND, + color: '#fff', + '&:hover': { + bgcolor: '#4D1C61' + } + } + } + }} + > + All Batches + Morning + Afternoon + Evening + + + + + newValue && setSelectedDate(newValue)} + slotProps={{ + textField: { + size: 'small', + sx: { + width: 180, + '& .MuiOutlinedInput-root': { + borderRadius: '20px', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: BRAND }, + '&.Mui-focused fieldset': { borderColor: BRAND } + } + } + } + }} + /> + + + + + + {/* Results Table/Cards */} + + + {isMobile ? ( + + {filteredRows?.length === 0 && ( + + + + + + No active riders to show + + + )} + + {filteredRows?.length !== 0 && + filteredRows?.map((row, index) => { + const statusMeta = getRowStatusMeta(row); + const StatusIcon = statusMeta.icon; + return ( + + + + + {String(index + 1).padStart(2, '0')} + + + #{row?.userid} + + + + + + + {statusMeta.label} + + + + + + + {(row.fullname || row.username || '?').charAt(0).toUpperCase()} + + + + {row.username || '—'} + + + {row.contactno || '—'} + + + + + } + > + + + option?.userid === value?.userid} + getOptionLabel={(option) => `${option.username || option.fullname || ''} (#${option.userid})`} + value={substituteAssignments[row.userid] || null} + onChange={(event, newValue) => handleAssignSubstitute(row.userid, newValue)} + renderInput={(params) => ( + + )} + /> + + + + ); + })} + + ) : ( + + + + # + ID + Rider + + Substitute Rider + Status + + + + {filteredRows?.length === 0 && ( + + + + + + + + No active riders to show + + + + + )} + + {filteredRows?.length !== 0 && + filteredRows?.map((row, index) => { + const statusMeta = getRowStatusMeta(row); + const StatusIcon = statusMeta.icon; + return ( + + + + {String(index + 1).padStart(2, '0')} + + + + + #{row?.userid} + + + + + + {(row.fullname || row.username || '?').charAt(0).toUpperCase()} + + + + {row.username || '—'} + + + {row.contactno || '—'} + + + + + + {/* Substitution Arrow & Autocomplete */} + + + + + option?.userid === value?.userid} + getOptionLabel={(option) => `${option.username || option.fullname || ''} (#${option.userid})`} + value={substituteAssignments[row.userid] || null} + onChange={(event, newValue) => handleAssignSubstitute(row.userid, newValue)} + renderInput={(params) => ( + + )} + /> + + + + + + + + + {statusMeta.label} + + + + + ); + })} + +
+ )} +
+ {hasAssignments && ( + + + + )} +
+ + ); +} diff --git a/src/pages/nearle/riders/riders.js b/src/pages/nearle/riders/riders.js index 6261598..3673e94 100644 --- a/src/pages/nearle/riders/riders.js +++ b/src/pages/nearle/riders/riders.js @@ -2,11 +2,12 @@ import * as React from 'react'; import { useState, useEffect, useRef, Fragment } from 'react'; import Geocode from 'react-geocode'; import { useNavigate } from 'react-router-dom'; +import axios from 'axios'; import { Avatar, Paper, Stack, - Typography, + Typography, Table, TableCell, TableBody, @@ -20,16 +21,19 @@ import { Grid, Box, Skeleton, - Divider, - useMediaQuery + useMediaQuery, + ToggleButtonGroup, + ToggleButton } from '@mui/material'; import { useTheme } from '@mui/material/styles'; var utc = require('dayjs/plugin/utc'); import dayjs from 'dayjs'; dayjs.extend(utc); +import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider'; +import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { DatePicker } from '@mui/x-date-pickers/DatePicker'; import { MdMyLocation, - MdPersonPin, MdCheckCircle, MdCancel, MdGroups, @@ -46,7 +50,8 @@ import { MdGpsFixed, MdAccessTime, MdInventory2, - MdTwoWheeler + MdTwoWheeler, + MdArrowForward } from 'react-icons/md'; import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete'; import PageHeader from 'components/nearle_components/PageHeader'; @@ -55,11 +60,11 @@ import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'compon import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar'; import CircularLoader from 'components/CircularLoader'; import { fetchAllRiders, getallridersummary, getriderstatus } from 'pages/api/api'; -import { useInfiniteQuery, useQuery } from '@tanstack/react-query'; +import { useInfiniteQuery, useQuery, useQueryClient } from '@tanstack/react-query'; import LoaderWithImage from 'components/nearle_components/LoaderWithImage'; import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton'; import { OpenToast } from 'components/third-party/OpenToast'; -import axios from 'axios'; +import RiderSubstitution from './RiderSubstitution'; // ============================================================================ // Design tokens — shared with the deliveries / tenants / customers pages so @@ -132,7 +137,9 @@ const STATUS_META = { // "ALL" view and emerald for "Active" so the colour matches the count's meaning. const TAB_META = [ { key: 0, label: 'All Riders', color: BRAND, icon: MdGroups, countKey: 'total' }, - { key: 1, label: 'Active', color: '#10b981', icon: MdCheckCircle, countKey: 'active' } + { key: 1, label: 'Active', color: '#10b981', icon: MdCheckCircle, countKey: 'active' }, + { key: 2, label: 'Substitutes', color: '#8b5cf6', icon: MdTwoWheeler, countKey: 'substitute' }, + { key: 3, label: 'Substitution History', color: '#f59e0b', icon: MdAccessTime, countKey: 'history' } ]; const KPI_META = (summary) => [ @@ -142,6 +149,7 @@ const KPI_META = (summary) => [ ]; const Riders = () => { + const queryClient = useQueryClient(); const navigate = useNavigate(); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down('md')); @@ -153,8 +161,184 @@ const Riders = () => { const [appId, setAppId] = useState(0); const [tabvalue, setTabvalue] = useState(0); const roleid = localStorage.getItem('roleid'); + const totalCols = roleid == 1 ? 11 : 10; const [logsRow, setLogsRow] = useState(null); const [riderLogsdata, setRiderLogsdata] = useState(null); + const [historyFromDate, setHistoryFromDate] = useState(dayjs().startOf('month')); + const [historyToDate, setHistoryToDate] = useState(dayjs().endOf('month')); + const [historyStatus, setHistoryStatus] = useState('all'); + + const [substituteAssignments, setSubstituteAssignments] = useState(() => { + try { + const saved = localStorage.getItem('rider_substitutions'); + return saved ? JSON.parse(saved) : {}; + } catch { + return {}; + } + }); + + const handleAssignSubstitute = (activeRiderId, substituteRider) => { + const updated = { + ...substituteAssignments, + [activeRiderId]: substituteRider + }; + setSubstituteAssignments(updated); + localStorage.setItem('rider_substitutions', JSON.stringify(updated)); + if (substituteRider) { + OpenToast(`Assigned ${substituteRider.username || substituteRider.fullname || 'Rider'} as substitute`, 'success', 2000); + } else { + OpenToast(`Removed substitute assignment`, 'info', 2000); + } + }; + + const handleFinalizeSuccess = () => { + setSubstituteAssignments({}); + localStorage.removeItem('rider_substitutions'); + queryClient.invalidateQueries({ queryKey: ['substitutionsHistory'] }); + queryClient.invalidateQueries({ queryKey: ['subRidersLogs'] }); + }; + + const { data: substituteRidersList } = useQuery({ + queryKey: ['allRidersForSub', appId], + queryFn: async () => { + try { + // Resolve partner ID dynamically + let partnerId = ''; + try { + const rawLocs = localStorage.getItem('applocations'); + if (rawLocs) { + const locs = JSON.parse(rawLocs); + const currentLoc = locs.find((l) => l.applocationid === appId); + if (currentLoc && currentLoc.partnerid) { + partnerId = currentLoc.partnerid; + } else { + const firstValidLoc = locs.find((l) => l.partnerid); + if (firstValidLoc && firstValidLoc.partnerid) { + partnerId = firstValidLoc.partnerid; + } + } + } + } catch (e) { + console.error('Error parsing applocations', e); + } + + if (!partnerId) { + const savedPartnerId = localStorage.getItem('partnerid'); + if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') { + partnerId = savedPartnerId; + } + } + + if (!partnerId) { + partnerId = 44; // final fallback + } + + const url = `${process.env.REACT_APP_URL}/partners/getallriders/?applocationid=${appId}&partnerid=${partnerId}`; + const res = await axios.get(url); + const allRiders = res.data.details || []; + const allowedIds = [1121, 772, 1116]; + return allRiders.filter((rider) => allowedIds.includes(parseInt(rider.userid))); + } catch (err) { + console.error(err); + return []; + } + } + }); + + const { data: subRidersLogsData, isLoading: subRidersLogsLoading } = useQuery({ + queryKey: ['subRidersLogs', appId, debouncedSearch], + queryFn: async () => { + try { + const todayStr = dayjs().format('YYYY-MM-DD'); + + // Resolve partner ID dynamically + let partnerId = ''; + try { + const rawLocs = localStorage.getItem('applocations'); + if (rawLocs) { + const locs = JSON.parse(rawLocs); + const currentLoc = locs.find((l) => l.applocationid === appId); + if (currentLoc && currentLoc.partnerid) { + partnerId = currentLoc.partnerid; + } else { + const firstValidLoc = locs.find((l) => l.partnerid); + if (firstValidLoc && firstValidLoc.partnerid) { + partnerId = firstValidLoc.partnerid; + } + } + } + } catch (e) { + console.error('Error parsing applocations', e); + } + + if (!partnerId) { + const savedPartnerId = localStorage.getItem('partnerid'); + if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') { + partnerId = savedPartnerId; + } + } + + if (!partnerId) { + partnerId = 44; + } + + const url = `${process.env.REACT_APP_URL2}/partners/getriderlogs/?applocationid=${appId}&partnerid=${partnerId}&fromdate=${todayStr}&todate=${todayStr}&keyword=${debouncedSearch}`; + const res = await axios.get(url); + return res.data.details || []; + } catch (err) { + console.error(err); + return []; + } + } + }); + + const { data: subHistoryData, isLoading: subHistoryLoading } = useQuery({ + queryKey: ['substitutionsHistory', appId, historyFromDate, historyToDate, historyStatus], + queryFn: async () => { + try { + let partnerId = ''; + try { + const rawLocs = localStorage.getItem('applocations'); + if (rawLocs) { + const locs = JSON.parse(rawLocs); + const currentLoc = locs.find((l) => l.applocationid === appId); + if (currentLoc && currentLoc.partnerid) { + partnerId = currentLoc.partnerid; + } else { + const firstValidLoc = locs.find((l) => l.partnerid); + if (firstValidLoc && firstValidLoc.partnerid) { + partnerId = firstValidLoc.partnerid; + } + } + } + } catch (e) { + console.error('Error parsing applocations', e); + } + + if (!partnerId) { + const savedPartnerId = localStorage.getItem('partnerid'); + if (savedPartnerId && savedPartnerId !== 'undefined' && savedPartnerId !== 'null') { + partnerId = savedPartnerId; + } + } + + if (!partnerId) { + partnerId = 44; + } + + let url = `${process.env.REACT_APP_URL}/substitutions?tenant_id=${partnerId}&from_date=${historyFromDate.format('YYYY-MM-DD')}&to_date=${historyToDate.format('YYYY-MM-DD')}`; + if (historyStatus !== 'all') { + url += `&status=${historyStatus}`; + } + const res = await axios.get(url); + return res.data.details || []; + } catch (err) { + console.error(err); + return []; + } + }, + enabled: tabvalue === 3 + }); Geocode.setApiKey(process.env.REACT_APP_GOOGLE_MAPS_API_KEY); @@ -262,7 +446,7 @@ const Riders = () => { <> theme.zIndex.drawer + 1 }} - open={allRidersLoading || riderSummarysLoading || riderStatusLoading} + open={allRidersLoading || riderSummarysLoading || riderStatusLoading || (tabvalue === 2 && subRidersLogsLoading) || (tabvalue === 3 && subHistoryLoading)} > @@ -343,7 +527,11 @@ const Riders = () => { {TAB_META.map((t) => { const Icon = t.icon; const active = tabvalue === t.key; - const count = allRidersSummary?.[t.countKey] ?? 0; + const count = t.key === 2 + ? Object.keys(substituteAssignments).filter(k => substituteAssignments[k]).length + : t.key === 3 + ? 0 + : (allRidersSummary?.[t.countKey] ?? 0); return ( { {t.label} - - {riderSummarysLoading ? : count} - + {t.key !== 3 && ( + + {riderSummarysLoading ? : count} + + )} ); })} @@ -427,8 +617,332 @@ const Riders = () => { - {/* ============================================= || Table || ============================================= */} - + + + + Status: + + val && setHistoryStatus(val)} + aria-label="status selection" + size="small" + sx={{ + '& .MuiToggleButton-root': { + borderRadius: '8px', + mx: 0.5, + border: `1px solid ${DT.borderSubtle}`, + color: DT.textSecondary, + fontWeight: 600, + textTransform: 'capitalize', + px: 2, + py: 0.5, + '&.Mui-selected': { + bgcolor: BRAND, + color: '#fff', + '&:hover': { + bgcolor: '#4D1C61' + } + } + } + }} + > + All + Scheduled + + + + + newValue && setHistoryFromDate(newValue)} + slotProps={{ + textField: { + size: 'small', + sx: { + width: 150, + '& .MuiOutlinedInput-root': { + borderRadius: '20px', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: BRAND }, + '&.Mui-focused fieldset': { borderColor: BRAND } + } + } + } + }} + /> + newValue && setHistoryToDate(newValue)} + slotProps={{ + textField: { + size: 'small', + sx: { + width: 150, + '& .MuiOutlinedInput-root': { + borderRadius: '20px', + '& fieldset': { borderColor: DT.borderSubtle }, + '&:hover fieldset': { borderColor: BRAND }, + '&.Mui-focused fieldset': { borderColor: BRAND } + } + } + } + }} + /> + + + + + )} + + {tabvalue === 3 ? ( + + {subHistoryData?.length === 0 ? ( + + + + + + No substitution history to show + + + Historical substitution logs will be listed here. + + + ) : isMobile ? ( + + {subHistoryData?.map((row, index) => ( + + + + {dayjs(row.sub_date).format('DD MMM YYYY')} + + + {row.status || 'scheduled'} + + + + + + Absent Rider + + + {row.absent_rider_name} + + + #{row.absent_rider_id} + + + + + + Substitute + + + {row.sub_rider_name} + + + #{row.sub_rider_id} + + + + {row.reason && ( + + + Reason: {row.reason} + + + )} + + } + /> + ))} + + ) : ( + + + + + Date + Absent Rider + + Substitute Rider + Reason + Status + + + + {subHistoryData?.map((row, index) => ( + + + + {dayjs(row.sub_date).format('DD MMM YYYY')} + + + + + + {(row.absent_rider_name || '?').charAt(0).toUpperCase()} + + + + {row.absent_rider_name} + + + #{row.absent_rider_id} + + + + + + + + + + + {(row.sub_rider_name || '?').charAt(0).toUpperCase()} + + + + {row.sub_rider_name} + + + #{row.sub_rider_id} + + + + + + + {row.reason || '—'} + + + + + {row.status || 'scheduled'} + + + + ))} + +
+
+ )} +
+ ) : tabvalue === 2 ? ( + + ) : ( + { + {expanded && tabvalue !== 0 && ( @@ -792,7 +1307,7 @@ const Riders = () => { {rows?.length === 0 && !allRidersLoading && ( - + @@ -1040,7 +1555,7 @@ const Riders = () => { {/* ============ Collapsible row — live rider logs ============ */} {expanded && tabvalue !== 0 && ( - + { {rows?.length !== 0 && ( - +
{isFetchingNextPage || hasNextPage ? ( @@ -1154,6 +1669,7 @@ const Riders = () => { )} + )} ); };