import React, {useEffect, useState, useRef} from "react"; import { useSelector,useDispatch } from "react-redux"; import { useParams, useNavigate } from "react-router-dom"; import { selectAllRoutes, transRoutesSlice, selectTomorrowAllRoutes, selectAllActiveVehicles, selectHistoryRoutes } from "./../../store"; import { Breadcrumb, Tabs, Tab } from "react-bootstrap"; import RouteCustomerEditor from "./RouteCustomerEditor"; import { AuthService, TransRoutesService, CustomerService, EventsService, EmployeeService } from "../../services"; import TimePicker from 'react-time-picker'; import 'react-time-picker/dist/TimePicker.css'; import moment from 'moment'; import { Archive, GripVertical } from "react-bootstrap-icons"; import { PERSONAL_ROUTE_STATUS } from "../../shared"; import { DndProvider } from 'react-dnd'; import { HTML5Backend } from 'react-dnd-html5-backend'; import { useDrag } from 'react-dnd'; const RouteEdit = () => { const params = useParams(); const allRoutes = useSelector(selectAllRoutes); const tomorrowRoutes = useSelector(selectTomorrowAllRoutes); const historyRoutes = useSelector(selectHistoryRoutes); const vehicles = useSelector(selectAllActiveVehicles); // const currentRoute = (allRoutes.find(item => item.id === params.id)) || (tomorrowRoutes.find(item => item.id === params.id)) || (historyRoutes.find(item => item.id === params.id)) || {}; const currentVehicle = vehicles.find(item => item.id === currentRoute?.vehicle ) || []; const navigate = useNavigate(); const dispatch = useDispatch(); const { updateRoute} = transRoutesSlice.actions; const [routeName, setRouteName] = useState(''); const [newDriver, setNewDriver] = useState(''); const [newVehicle, setNewVehicle] = useState(''); const [newRouteType, setNewRouteType] = useState(''); const [newCustomerList, setNewCustomerList] = useState([]); const [errorMessage, setErrorMessage] = useState(undefined); const [estimatedStartTime, setEstimatedStartTime] = useState(undefined); const [currentRoute, setCurrentRoute] = useState(undefined); const [allCustomers, setAllCustomers] = useState([]); const [driverOptions, setDriverOptions] = useState([]); const [unassignedCustomers, setUnassignedCustomers] = useState([]); const [addCustomerToRoute, setAddCustomerToRoute] = useState(null); const [attendanceAbsentCustomers, setAttendanceAbsentCustomers] = useState([]); // customers with attendance notes on route date const initialCustomerListRef = useRef(null); const paramsQuery = new URLSearchParams(window.location.search); const isDriverEligibleEmployee = (employee) => { if (!employee || employee.status !== 'active') return false; const roles = Array.isArray(employee.roles) ? employee.roles.map((item) => `${item || ''}`.toLowerCase()) : []; const permissions = Array.isArray(employee.permissions) ? employee.permissions : []; return roles.includes('driver') || permissions.includes('isDriver'); }; const buildExternalDriverOptions = (records = []) => { return (records || []) .filter((record) => Array.isArray(record?.permissions) && record.permissions.includes('isDriver')) .map((record) => ({ id: record?.external_user_id, name: record?.name || record?.username || '', name_cn: '', title: record?.title || '', employment_status: 'external', license_type: '', phone: '', email: record?.email || '', status: 'active' })) .filter((record) => record.id && record.name); }; const scheduleDate = paramsQuery.get('dateSchedule'); const editSection = paramsQuery.get('editSection') const hasUnsavedCustomerChanges = () => { if (!initialCustomerListRef.current || editSection !== 'assignment') return false; const current = JSON.stringify( (newCustomerList || []).map(c => c.customer_id).sort() ); return current !== initialCustomerListRef.current; }; const confirmNavigate = (navigateFn) => { if (hasUnsavedCustomerChanges()) { if (window.confirm('You have unsaved changes to the customer assignment. Are you sure you want to leave without saving?')) { navigateFn(); } } else { navigateFn(); } }; // Warn on browser refresh / tab close useEffect(() => { const handleBeforeUnload = (e) => { if (hasUnsavedCustomerChanges()) { e.preventDefault(); e.returnValue = ''; } }; window.addEventListener('beforeunload', handleBeforeUnload); return () => window.removeEventListener('beforeunload', handleBeforeUnload); }, [newCustomerList, editSection]); const redirectToView = () => { const go = () => { if (scheduleDate) { navigate(`/trans-routes/${params.id}?dateSchedule=${scheduleDate}`); } else { navigate(`/trans-routes/${params.id}`); } }; confirmNavigate(go); } const redirectToDashboard = () => { confirmNavigate(() => navigate(`/trans-routes/dashboard`)); } const softDeleteCurrentRoute = () => { if (!window.confirm('Are you sure you want to archive this route? This action cannot be undone.')) { return; } const data = Object.assign({}, currentRoute, {status: ['disabled']}) dispatch(updateRoute({ id: currentRoute?.id, data, callback: redirectToDashboard })); // redirectToDashboard(); } const validateRoute = () => { const errors = []; // Required fields validation if (!routeName || routeName.trim() === '') { errors.push('Route Name'); } if (!newRouteType || newRouteType === '') { errors.push('Route Type'); } if (!newDriver || newDriver === '') { errors.push('Driver'); } if (!newVehicle || newVehicle === '') { errors.push('Vehicle'); } if (errors.length > 0) { window.alert(`Please fill in the following required fields:\n${errors.join('\n')}`); return false; } return true; }; const normalizeAddressText = (value) => { return (value || '') .toString() .toLowerCase() .replace(/\([^)]*\)/g, ' ') .replace(/[^a-z0-9]/g, ' ') .replace(/\s+/g, ' ') .trim(); }; const formatStructuredAddress = (line1, line2, city, state, zipCode, note) => { const cityState = [city, state].filter(Boolean).join(', '); const mainAddress = [line1, line2, cityState, zipCode] .filter((item) => item && String(item).trim() !== '') .join(' ') .trim(); const addressNote = (note || '').trim(); if (!mainAddress) return ''; return addressNote ? `${mainAddress} (${addressNote})` : mainAddress; }; const getStructuredAddresses = (customerProfile) => { if (!customerProfile) return []; return [ formatStructuredAddress(customerProfile.address_line_1, customerProfile.address_line_2, customerProfile.city, customerProfile.state, customerProfile.zip_code, customerProfile.address_note), formatStructuredAddress(customerProfile.address2_line_1, customerProfile.address2_line_2, customerProfile.city2, customerProfile.state2, customerProfile.zip_code2, customerProfile.address2_note), formatStructuredAddress(customerProfile.address3_line_1, customerProfile.address3_line_2, customerProfile.city3, customerProfile.state3, customerProfile.zip_code3, customerProfile.address3_note), formatStructuredAddress(customerProfile.address4_line_1, customerProfile.address4_line_2, customerProfile.city4, customerProfile.state4, customerProfile.zip_code4, customerProfile.address4_note) ].filter((addr) => (addr || '').trim() !== ''); }; const updateCurrentRoute = () => { try { if (!validateRoute()) { return; } // Do not persist scheduled-absence entries into route_customer_list. const filteredCustomerList = (newCustomerList || []).filter( (customer) => customer?.customer_route_status !== PERSONAL_ROUTE_STATUS.SCHEDULED_ABSENT ); const existingRouteCustomers = currentRoute?.route_customer_list || []; const existingByCustomerId = new Map(existingRouteCustomers.map((c) => [c?.customer_id, c])); const customerProfileById = new Map((allCustomers || []).map((c) => [c?.id, c])); const stabilizedCustomerList = filteredCustomerList.map((customerItem) => { const existingCustomer = existingByCustomerId.get(customerItem?.customer_id); if (!existingCustomer?.customer_address) return customerItem; const customerProfile = customerProfileById.get(customerItem?.customer_id); const structuredAddresses = getStructuredAddresses(customerProfile); if (structuredAddresses.length === 0) return customerItem; const structuredAddressSet = new Set(structuredAddresses.map(normalizeAddressText).filter(Boolean)); const existingAddressNormalized = normalizeAddressText(existingCustomer.customer_address); if (!existingAddressNormalized) return customerItem; // Keep the route's current address if it no longer exists in DB structured addresses. if (!structuredAddressSet.has(existingAddressNormalized)) { return Object.assign({}, customerItem, { customer_address: existingCustomer.customer_address }); } return customerItem; }); let data = Object.assign({}, currentRoute, {name: routeName, driver: newDriver, vehicle: newVehicle, type: newRouteType, route_customer_list: stabilizedCustomerList}); if (estimatedStartTime && estimatedStartTime !== '') { data = Object.assign({}, data, {estimated_start_time: combineDateAndTime(currentRoute.schedule_date, estimatedStartTime)}) } let payload = { id: currentRoute?.id, data }; if ((historyRoutes.find(item => item.id === params.id)) || (scheduleDate && new Date(data.schedule_date) > new Date())) { payload = Object.assign({}, payload, {dateText: data.schedule_date}); if (scheduleDate && new Date(data.schedule_date) > new Date()) { payload = Object.assign({}, payload, {fromSchedule: true}); } } initialCustomerListRef.current = JSON.stringify( (newCustomerList || []).map(c => c.customer_id).sort() ); payload.callback = redirectToView; dispatch(updateRoute(payload)); // TransRoutesService.updateInProgress(data); // setTimeout(() => { // redirectToView(); // }, 5000); } catch(ex) { } } const combineDateAndTime = (date, time) => { const dateObj = moment(date); const timeObj = moment(time, 'HH:mm'); dateObj.set({ hour: timeObj.get('hour'), minute: timeObj.get('minute'), second: timeObj.get('second') }) return dateObj; } const calculateUnassignedCustomers = (customers, routes) => { if (!customers || !routes) return []; // Get all customer IDs that are assigned to any route today const assignedCustomerIds = new Set(); routes.forEach(route => { route.route_customer_list?.forEach(customer => { assignedCustomerIds.add(customer.customer_id); }); }); // Filter out customers that are not assigned to any route // Also exclude discharged customers (type is 'discharged' or name contains 'discharged') return customers.filter(customer => { const isDischarged = customer.type === 'discharged' || (customer.name && customer.name.toLowerCase().includes('discharged')) || (customer.status !== 'active'); return customer.status === 'active' && !assignedCustomerIds.has(customer.id) && !isDischarged; }); } // Helper: check if a recurring rule has an occurrence on a specific date const recurRuleFallsOnDate = (rule, targetDate) => { const start = new Date(rule.start_repeat_date); const end = new Date(rule.end_repeat_date); const target = new Date(targetDate); // Normalize to date-only comparison start.setHours(0, 0, 0, 0); end.setHours(0, 0, 0, 0); target.setHours(0, 0, 0, 0); if (target < start || target > end) return false; const freq = rule.rrule; let current = new Date(start); let count = 0; while (current <= target && count < 5000) { if (current.getTime() === target.getTime()) return true; if (freq === 'FREQ=DAILY') { current = new Date(current.getTime() + 24 * 60 * 60 * 1000); } else if (freq === 'FREQ=WEEKLY') { current = new Date(current.getTime() + 7 * 24 * 60 * 60 * 1000); } else if (freq === 'FREQ=MONTHLY') { const next = new Date(current); next.setMonth(next.getMonth() + 1); current = next; } else if (freq === 'FREQ=YEARLY') { const next = new Date(current); next.setFullYear(next.getFullYear() + 1); current = next; } else { break; } count++; } return false; }; useEffect(() => { if (!AuthService.canAddOrEditRoutes()) { window.alert('You haven\'t login yet OR this user does not have access to this page. Please change an admin account to login.') AuthService.logout(); navigate(`/login`); } TransRoutesService.getRoute(params.id).then(data => { setCurrentRoute(data?.data); setRouteName(data?.data?.name); setNewDriver(data?.data?.driver); setNewVehicle(data?.data?.vehicle); setNewRouteType(data?.data?.type); setEstimatedStartTime(data?.data?.estimated_start_time && new Date(data?.data?.estimated_start_time)); setNewCustomerList(data?.data?.route_customer_list); initialCustomerListRef.current = JSON.stringify( (data?.data?.route_customer_list || []).map(c => c.customer_id).sort() ); setErrorMessage(undefined); }) // Fetch all customers CustomerService.getAllCustomers().then(data => { setAllCustomers(data?.data || []); }); Promise.all([ EmployeeService.getAllEmployees(), EmployeeService.getExternalUserPermissionsList(EventsService.site) ]).then(([employeeResponse, externalPermissionResponse]) => { const employees = employeeResponse?.data || []; const internalDrivers = employees.filter(isDriverEligibleEmployee); const externalDrivers = buildExternalDriverOptions(externalPermissionResponse?.data || []); const mergedById = new Map(); [...internalDrivers, ...externalDrivers].forEach((driverItem) => { if (!driverItem?.id) return; if (!mergedById.has(driverItem.id)) { mergedById.set(driverItem.id, driverItem); } }); setDriverOptions(Array.from(mergedById.values())); }); }, []); // Calculate unassigned customers when allCustomers or routes change useEffect(() => { if (!currentRoute?.schedule_date) return; const routeDate = currentRoute.schedule_date; // Get routes from the same date as the current route (excluding current route which we'll handle separately) const sameDateRoutes = [ ...allRoutes.filter(route => route.schedule_date === routeDate && route.id !== currentRoute.id), ...tomorrowRoutes.filter(route => route.schedule_date === routeDate && route.id !== currentRoute.id), ...historyRoutes.filter(route => route.schedule_date === routeDate && route.id !== currentRoute.id) ]; // Add a virtual route with the current newCustomerList (to include customers being added in the editor) const routesWithCurrentEdits = [ ...sameDateRoutes, { route_customer_list: newCustomerList || [] } ]; // Also exclude attendance-absent customers from unassigned list const attendanceAbsentIds = new Set(attendanceAbsentCustomers.map(c => c.customer_id)); const unassigned = calculateUnassignedCustomers(allCustomers, routesWithCurrentEdits) .filter(customer => !attendanceAbsentIds.has(customer.id)); setUnassignedCustomers(unassigned); }, [allCustomers, allRoutes, tomorrowRoutes, historyRoutes, currentRoute, newCustomerList, attendanceAbsentCustomers]); // Fetch attendance notes (single + recurring) for the route date useEffect(() => { if (!currentRoute?.schedule_date || !allCustomers?.length) return; const routeDate = currentRoute.schedule_date; // MM/DD/YYYY // Convert to YYYY-MM-DD for the API const dateParts = routeDate.split('/'); if (dateParts.length !== 3) return; const apiDate = `${dateParts[2]}-${dateParts[0].padStart(2, '0')}-${dateParts[1].padStart(2, '0')}`; // Parse into a Date object for recurring rule checking const routeDateObj = new Date(parseInt(dateParts[2]), parseInt(dateParts[0]) - 1, parseInt(dateParts[1])); routeDateObj.setHours(0, 0, 0, 0); // Fetch single attendance notes and recurring rules in parallel Promise.all([ EventsService.getAllEvents({ date: apiDate, type: 'incident' }), EventsService.getAllEventRecurrences() ]).then(([eventsRes, recurRes]) => { const absentCustomerIds = new Set(); const attendanceReasonsByCustomer = new Map(); const addAttendanceReason = (customerId, reasonText) => { if (!customerId || !reasonText) return; const cleaned = `${reasonText}`.trim(); if (!cleaned) return; const existing = attendanceReasonsByCustomer.get(customerId) || []; if (!existing.includes(cleaned)) { attendanceReasonsByCustomer.set(customerId, [...existing, cleaned]); } }; const extractAttendanceReason = (item) => { return item?.description || item?.data?.description || item?.data?.note || item?.title || ''; }; // Single attendance notes for this date const singleNotes = (eventsRes?.data || []).filter( e => e.type === 'incident' && e.status === 'active' && e.target_uuid ); singleNotes.forEach(note => { absentCustomerIds.add(note.target_uuid); addAttendanceReason(note.target_uuid, extractAttendanceReason(note)); }); // Recurring attendance rules that fall on this date const recurRules = (recurRes?.data || []).filter( r => r.type === 'incident' && r.status === 'active' && r.target_uuid && r.rrule ); recurRules.forEach(rule => { if (recurRuleFallsOnDate(rule, routeDateObj)) { absentCustomerIds.add(rule.target_uuid); addAttendanceReason(rule.target_uuid, extractAttendanceReason(rule)); } }); // Build the list of absent customer objects const absentList = []; absentCustomerIds.forEach(customerId => { const customer = allCustomers.find(c => c.id === customerId); if (customer) { const reasons = attendanceReasonsByCustomer.get(customer.id) || []; absentList.push({ customer_id: customer.id, customer_name: customer.name, customer_address: customer.address1 || '', customer_route_status: PERSONAL_ROUTE_STATUS.SCHEDULED_ABSENT, customer_pickup_status: 'scheduleAbsent', attendance_note: reasons.join('; '), _attendance_based: true // flag to identify these are from attendance notes }); } }); setAttendanceAbsentCustomers(absentList); }); }, [currentRoute?.schedule_date, allCustomers]); // useEffect(() => { // if (currentRoute) { // setRouteName(currentRoute.name); // setNewDriver(currentRoute.driver); // setNewVehicle(currentRoute.vehicle); // setNewRouteType(currentRoute.type); // setEstimatedStartTime(currentRoute.estimated_start_time && new Date(currentRoute.estimated_start_time)); // setNewCustomerList(currentRoute.route_customer_list); // } // setErrorMessage(undefined); // }, [currentRoute]) // Draggable component for unassigned customers const DraggableUnassignedCustomer = ({ customer }) => { const [{ isDragging }, drag] = useDrag({ type: 'UNASSIGNED_CUSTOMER', item: () => customer, collect: (monitor) => ({ isDragging: monitor.isDragging(), }), }); const opacity = isDragging ? 0.5 : 1; return (
| {item} |