diff --git a/frontend/src/components/NavBarItem.tsx b/frontend/src/components/NavBarItem.tsx index 72935e6..fcbd9b9 100644 --- a/frontend/src/components/NavBarItem.tsx +++ b/frontend/src/components/NavBarItem.tsx @@ -1,6 +1,5 @@ -import React, {useEffect, useRef} from 'react' +import React, { useEffect, useRef, useState } from 'react' import Link from 'next/link' -import { useState } from 'react' import { mdiChevronUp, mdiChevronDown } from '@mdi/js' import BaseDivider from './BaseDivider' import BaseIcon from './BaseIcon' diff --git a/frontend/src/layouts/Authenticated.tsx b/frontend/src/layouts/Authenticated.tsx index 1b9907d..73d8391 100644 --- a/frontend/src/layouts/Authenticated.tsx +++ b/frontend/src/layouts/Authenticated.tsx @@ -1,5 +1,4 @@ -import React, { ReactNode, useEffect } from 'react' -import { useState } from 'react' +import React, { ReactNode, useEffect, useState } from 'react' import jwt from 'jsonwebtoken'; import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js' import menuAside from '../menuAside' diff --git a/frontend/src/menuAside.ts b/frontend/src/menuAside.ts index cd3d8e5..4f02c99 100644 --- a/frontend/src/menuAside.ts +++ b/frontend/src/menuAside.ts @@ -7,6 +7,12 @@ const menuAside: MenuAsideItem[] = [ icon: icon.mdiViewDashboardOutline, label: 'Dashboard', }, + { + href: '/dispatch-control', + icon: icon.mdiTruckFast, + label: 'Dispatch control', + permissions: 'READ_TRUCK_REQUESTS' + }, { href: '/users/users-list', diff --git a/frontend/src/pages/dispatch-control.tsx b/frontend/src/pages/dispatch-control.tsx new file mode 100644 index 0000000..948396b --- /dev/null +++ b/frontend/src/pages/dispatch-control.tsx @@ -0,0 +1,806 @@ +import * as icon from '@mdi/js'; +import axios from 'axios'; +import Head from 'next/head'; +import React, { ReactElement, useEffect, useMemo, useState } from 'react'; +import BaseButton from '../components/BaseButton'; +import BaseIcon from '../components/BaseIcon'; +import CardBox from '../components/CardBox'; +import SectionMain from '../components/SectionMain'; +import LayoutAuthenticated from '../layouts/Authenticated'; +import { getPageTitle } from '../config'; +import { hasPermission } from '../helpers/userPermissions'; +import { useAppSelector } from '../stores/hooks'; + +type EntityOption = { + id: string; + label?: string; + [key: string]: any; +}; + +type TruckRequest = { + id: string; + request_code?: string; + request_type?: string; + status?: string; + origin_location?: string; + destination_location?: string; + estimated_weight_kg?: string | number; + estimated_packages_count?: number; + requested_pickup_window_start?: string; + requested_pickup_window_end?: string; + remarks?: string; + requires_dock?: boolean; + facility?: EntityOption; + priority?: EntityOption; + requester?: EntityOption; + request_approvals_truck_request?: any[]; + truck_assignments_truck_request?: any[]; + realtime_updates_truck_request?: any[]; +}; + +type FormState = { + request_type: string; + origin_location: string; + destination_location: string; + requested_pickup_window_start: string; + requested_pickup_window_end: string; + estimated_packages_count: string; + estimated_weight_kg: string; + facility: string; + priority: string; + requires_dock: boolean; + remarks: string; +}; + +type AssignmentState = { + truck: string; + driver: string; + dock: string; + scheduled_arrival_at: string; + scheduled_departure_at: string; + notes: string; +}; + +const brand = { + orange: '#EE4D2D', + teal: '#00B8A9', +}; + +const requestTypes = [ + { value: 'inbound_pickup', label: 'Inbound pickup' }, + { value: 'outbound_delivery', label: 'Outbound delivery' }, + { value: 'transfer', label: 'Facility transfer' }, + { value: 'return', label: 'Return' }, + { value: 'other', label: 'Other' }, +]; + +const statusTone: Record = { + draft: 'bg-slate-100 text-slate-700', + submitted: 'bg-blue-100 text-blue-700', + under_review: 'bg-amber-100 text-amber-800', + approved: 'bg-emerald-100 text-emerald-800', + rejected: 'bg-red-100 text-red-700', + scheduled: 'bg-indigo-100 text-indigo-700', + assigned: 'bg-purple-100 text-purple-700', + arrived: 'bg-cyan-100 text-cyan-800', + docked: 'bg-orange-100 text-orange-800', + loading: 'bg-pink-100 text-pink-700', + completed: 'bg-green-100 text-green-800', + cancelled: 'bg-gray-200 text-gray-700', +}; + +const initialForm = (): FormState => { + const start = new Date(Date.now() + 60 * 60 * 1000); + const end = new Date(Date.now() + 3 * 60 * 60 * 1000); + + return { + request_type: 'outbound_delivery', + origin_location: 'Shopee Sorting Facility - Dispatch Bay', + destination_location: '', + requested_pickup_window_start: toDatetimeLocal(start), + requested_pickup_window_end: toDatetimeLocal(end), + estimated_packages_count: '', + estimated_weight_kg: '', + facility: '', + priority: '', + requires_dock: true, + remarks: '', + }; +}; + +const initialAssignment = (): AssignmentState => { + const arrival = new Date(Date.now() + 2 * 60 * 60 * 1000); + const departure = new Date(Date.now() + 4 * 60 * 60 * 1000); + + return { + truck: '', + driver: '', + dock: '', + scheduled_arrival_at: toDatetimeLocal(arrival), + scheduled_departure_at: toDatetimeLocal(departure), + notes: '', + }; +}; + +function toDatetimeLocal(date: Date) { + const offset = date.getTimezoneOffset(); + const local = new Date(date.getTime() - offset * 60 * 1000); + return local.toISOString().slice(0, 16); +} + +function toIso(value?: string) { + return value ? new Date(value).toISOString() : null; +} + +function humanize(value?: string) { + return value ? value.replace(/_/g, ' ') : 'Not set'; +} + +function optionLabel(item: EntityOption, fields: string[]) { + return fields.map((field) => item?.[field]).filter(Boolean).join(' · ') || item?.label || item?.id; +} + +function requestReference(request?: TruckRequest | null) { + return request?.request_code || request?.id || 'request'; +} + +const DispatchControlPage = () => { + const { currentUser } = useAppSelector((state) => state.auth); + const [requests, setRequests] = useState([]); + const [selectedId, setSelectedId] = useState(''); + const [selected, setSelected] = useState(null); + const [facilities, setFacilities] = useState([]); + const [priorities, setPriorities] = useState([]); + const [trucks, setTrucks] = useState([]); + const [drivers, setDrivers] = useState([]); + const [docks, setDocks] = useState([]); + const [auditEvents, setAuditEvents] = useState([]); + const [form, setForm] = useState(initialForm); + const [assignment, setAssignment] = useState(initialAssignment); + const [loading, setLoading] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState<{ type: 'success' | 'error' | 'info'; text: string } | null>(null); + + const permissions = useMemo(() => ({ + canCreateRequest: hasPermission(currentUser, 'CREATE_TRUCK_REQUESTS'), + canApprove: hasPermission(currentUser, ['CREATE_REQUEST_APPROVALS', 'UPDATE_TRUCK_REQUESTS']), + canAssign: hasPermission(currentUser, ['CREATE_TRUCK_ASSIGNMENTS', 'UPDATE_TRUCK_REQUESTS']), + canConfirmDock: hasPermission(currentUser, ['CREATE_DOCK_CONFIRMATIONS', 'UPDATE_TRUCK_ASSIGNMENTS', 'UPDATE_TRUCK_REQUESTS']), + }), [currentUser]); + + const selectedAssignment = selected?.truck_assignments_truck_request?.[0]; + const activeQueue = requests.filter((request) => !['completed', 'cancelled', 'rejected'].includes(request.status || '')).length; + const assignedQueue = requests.filter((request) => ['assigned', 'arrived', 'docked', 'loading'].includes(request.status || '')).length; + + useEffect(() => { + loadWorkspace(); + }, []); + + useEffect(() => { + if (!selectedId) { + setSelected(null); + return; + } + + loadSelected(selectedId); + }, [selectedId]); + + async function safeList(endpoint: string, setter: (rows: EntityOption[]) => void, label: string) { + try { + const response = await axios.get(`${endpoint}?limit=100&page=0`); + setter(response.data?.rows || []); + } catch (error) { + console.error(`Failed to load ${label}`, error); + setNotice({ type: 'info', text: `Some ${label} options could not load for your role. You can still use the request queue.` }); + } + } + + async function loadWorkspace() { + setLoading(true); + try { + const [requestsResponse, auditResponse] = await Promise.all([ + axios.get('truck_requests?limit=30&page=0'), + axios.get('audit_events?limit=8&page=0').catch((error) => { + console.error('Failed to load audit events', error); + return { data: { rows: [] } }; + }), + ]); + + const rows = requestsResponse.data?.rows || []; + setRequests(rows); + setAuditEvents(auditResponse.data?.rows || []); + setSelectedId((current) => current || rows[0]?.id || ''); + + await Promise.all([ + safeList('sorting_facilities', setFacilities, 'facilities'), + safeList('request_priorities', setPriorities, 'priority'), + safeList('trucks', setTrucks, 'truck'), + safeList('drivers', setDrivers, 'driver'), + safeList('docks', setDocks, 'dock'), + ]); + } catch (error) { + console.error('Failed to load dispatch workspace', error); + setNotice({ type: 'error', text: readableError(error, 'Unable to load dispatch workspace.') }); + } finally { + setLoading(false); + } + } + + async function loadSelected(id: string) { + try { + const response = await axios.get(`truck_requests/${id}`); + setSelected(response.data); + } catch (error) { + console.error('Failed to load request detail', error); + setNotice({ type: 'error', text: readableError(error, 'Unable to load request details.') }); + } + } + + async function logEvent(request: TruckRequest | null, action: string, summary: string, details?: string, assignmentId?: string) { + const now = new Date().toISOString(); + + await axios.post('realtime_updates', { + data: { + update_type: action === 'assign' ? 'truck_change' : 'status_change', + reported_at: now, + message: summary, + truck_request: request?.id || null, + truck_assignment: assignmentId || null, + reported_by: currentUser?.id || null, + }, + }); + + await axios.post('audit_events', { + data: { + event_at: now, + entity_type: assignmentId ? 'truck_assignment' : 'truck_request', + entity_reference: requestReference(request), + action, + summary, + details: details || summary, + actor: currentUser?.id || null, + }, + }); + } + + async function refreshAfterAction(targetId?: string) { + const response = await axios.get('truck_requests?limit=30&page=0'); + const rows = response.data?.rows || []; + setRequests(rows); + const nextId = targetId || selectedId || rows[0]?.id || ''; + setSelectedId(nextId); + if (nextId) await loadSelected(nextId); + + const auditResponse = await axios.get('audit_events?limit=8&page=0').catch((error) => { + console.error('Failed to refresh audit events', error); + return { data: { rows: [] } }; + }); + setAuditEvents(auditResponse.data?.rows || []); + } + + async function handleCreateRequest(event: React.FormEvent) { + event.preventDefault(); + setNotice(null); + + if (!permissions.canCreateRequest) { + setNotice({ type: 'error', text: 'Your role cannot create truck requests.' }); + return; + } + + if (!form.destination_location.trim()) { + setNotice({ type: 'error', text: 'Destination location is required.' }); + return; + } + + if (!form.estimated_packages_count || Number(form.estimated_packages_count) <= 0) { + setNotice({ type: 'error', text: 'Package count must be greater than zero.' }); + return; + } + + const code = `SF-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-${Math.floor(1000 + Math.random() * 9000)}`; + setSubmitting(true); + + try { + await axios.post('truck_requests', { + data: { + request_code: code, + request_type: form.request_type, + status: 'submitted', + requested_pickup_window_start: toIso(form.requested_pickup_window_start), + requested_pickup_window_end: toIso(form.requested_pickup_window_end), + origin_location: form.origin_location, + destination_location: form.destination_location, + estimated_weight_kg: form.estimated_weight_kg || null, + estimated_packages_count: Number(form.estimated_packages_count), + remarks: form.remarks, + requires_dock: form.requires_dock, + facility: form.facility || null, + priority: form.priority || null, + requester: currentUser?.id || null, + }, + }); + + const lookup = await axios.get(`truck_requests?request_code=${encodeURIComponent(code)}&limit=1&page=0`); + const created = lookup.data?.rows?.[0] || null; + await logEvent(created, 'submit', `${code} submitted for approval`, form.remarks || 'New truck request submitted.'); + setForm(initialForm()); + setNotice({ type: 'success', text: `${code} was submitted and added to the dispatch queue.` }); + await refreshAfterAction(created?.id); + } catch (error) { + console.error('Failed to create truck request', error); + setNotice({ type: 'error', text: readableError(error, 'Unable to submit truck request.') }); + } finally { + setSubmitting(false); + } + } + + async function handleApprove() { + if (!selected) return; + if (!permissions.canApprove) { + setNotice({ type: 'error', text: 'Your role cannot approve requests.' }); + return; + } + + setSubmitting(true); + try { + await axios.post('request_approvals', { + data: { + truck_request: selected.id, + approver: currentUser?.id || null, + decision: 'approved', + decided_at: new Date().toISOString(), + decision_notes: 'Approved from Dispatch Control MVP workflow.', + }, + }); + await axios.put(`truck_requests/${selected.id}`, { id: selected.id, data: { status: 'approved' } }); + await logEvent(selected, 'approve', `${requestReference(selected)} approved`, 'Supervisor approval recorded.'); + setNotice({ type: 'success', text: `${requestReference(selected)} approved and ready for assignment.` }); + await refreshAfterAction(selected.id); + } catch (error) { + console.error('Failed to approve request', error); + setNotice({ type: 'error', text: readableError(error, 'Unable to approve this request.') }); + } finally { + setSubmitting(false); + } + } + + async function handleAssign(event: React.FormEvent) { + event.preventDefault(); + if (!selected) return; + if (!permissions.canAssign) { + setNotice({ type: 'error', text: 'Your role cannot assign trucks.' }); + return; + } + + if (!assignment.truck || !assignment.driver || !assignment.dock) { + setNotice({ type: 'error', text: 'Truck, driver, and dock are required before assignment.' }); + return; + } + + const assignmentCode = `ASG-${Date.now().toString().slice(-6)}`; + setSubmitting(true); + try { + await axios.post('truck_assignments', { + data: { + assignment_code: assignmentCode, + truck_request: selected.id, + dispatcher: currentUser?.id || null, + truck: assignment.truck, + driver: assignment.driver, + dock: assignment.dock, + scheduled_arrival_at: toIso(assignment.scheduled_arrival_at), + scheduled_departure_at: toIso(assignment.scheduled_departure_at), + assignment_status: 'assigned', + notes: assignment.notes || 'Assigned from Dispatch Control MVP workflow.', + }, + }); + await axios.put(`truck_requests/${selected.id}`, { id: selected.id, data: { status: 'assigned' } }); + await logEvent(selected, 'assign', `${requestReference(selected)} assigned to truck and dock`, `Assignment ${assignmentCode} created.`); + setAssignment(initialAssignment()); + setNotice({ type: 'success', text: `${requestReference(selected)} assigned. Dock confirmation can now be recorded.` }); + await refreshAfterAction(selected.id); + } catch (error) { + console.error('Failed to assign truck', error); + setNotice({ type: 'error', text: readableError(error, 'Unable to assign truck.') }); + } finally { + setSubmitting(false); + } + } + + async function handleConfirmDock() { + if (!selected || !selectedAssignment?.id) return; + if (!permissions.canConfirmDock) { + setNotice({ type: 'error', text: 'Your role cannot confirm dock activity.' }); + return; + } + + setSubmitting(true); + try { + await axios.post('dock_confirmations', { + data: { + truck_assignment: selectedAssignment.id, + dock_operator: currentUser?.id || null, + confirmed_at: new Date().toISOString(), + confirmation_type: 'dock_in', + comments: 'Dock-in confirmed from Dispatch Control MVP workflow.', + }, + }); + await axios.put(`truck_assignments/${selectedAssignment.id}`, { + id: selectedAssignment.id, + data: { assignment_status: 'docked', actual_arrival_at: new Date().toISOString() }, + }); + await axios.put(`truck_requests/${selected.id}`, { id: selected.id, data: { status: 'docked' } }); + await logEvent(selected, 'confirm', `${requestReference(selected)} dock-in confirmed`, 'Dock operator confirmed the vehicle is at dock.', selectedAssignment.id); + setNotice({ type: 'success', text: `${requestReference(selected)} dock-in confirmed and audit trail updated.` }); + await refreshAfterAction(selected.id); + } catch (error) { + console.error('Failed to confirm dock', error); + setNotice({ type: 'error', text: readableError(error, 'Unable to confirm dock-in.') }); + } finally { + setSubmitting(false); + } + } + + function fieldClass() { + return 'w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-orange-400 focus:ring-4 focus:ring-orange-100 dark:border-dark-700 dark:bg-dark-800 dark:text-white'; + } + + return ( + <> + + {getPageTitle('Dispatch Control')} + + +
+
+
+
+ Shopee Sorting Facility Dispatch +
+

Dispatch Control Room

+

+ A thin, working slice that lets the team submit truck requests, approve them, assign fleet resources, confirm docking, and preserve the event history. +

+
+
+ + + +
+
+
+ + {notice && ( +
+ {notice.text} +
+ )} + +
+ +
+
+

Intake

+

New truck request

+
+ Requester role +
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + + +
+ + + +