|
|
|
|
@ -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<string, string> = {
|
|
|
|
|
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<TruckRequest[]>([]);
|
|
|
|
|
const [selectedId, setSelectedId] = useState('');
|
|
|
|
|
const [selected, setSelected] = useState<TruckRequest | null>(null);
|
|
|
|
|
const [facilities, setFacilities] = useState<EntityOption[]>([]);
|
|
|
|
|
const [priorities, setPriorities] = useState<EntityOption[]>([]);
|
|
|
|
|
const [trucks, setTrucks] = useState<EntityOption[]>([]);
|
|
|
|
|
const [drivers, setDrivers] = useState<EntityOption[]>([]);
|
|
|
|
|
const [docks, setDocks] = useState<EntityOption[]>([]);
|
|
|
|
|
const [auditEvents, setAuditEvents] = useState<any[]>([]);
|
|
|
|
|
const [form, setForm] = useState<FormState>(initialForm);
|
|
|
|
|
const [assignment, setAssignment] = useState<AssignmentState>(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 (
|
|
|
|
|
<>
|
|
|
|
|
<Head>
|
|
|
|
|
<title>{getPageTitle('Dispatch Control')}</title>
|
|
|
|
|
</Head>
|
|
|
|
|
<SectionMain>
|
|
|
|
|
<div className="mb-6 overflow-hidden rounded-[2rem] bg-gradient-to-br from-[#06111f] via-[#102139] to-[#EE4D2D] p-8 text-white shadow-xl">
|
|
|
|
|
<div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="mb-4 inline-flex rounded-full bg-white/10 px-4 py-2 text-sm font-semibold text-orange-100 ring-1 ring-white/15">
|
|
|
|
|
Shopee Sorting Facility Dispatch
|
|
|
|
|
</div>
|
|
|
|
|
<h1 className="text-4xl font-black tracking-tight md:text-5xl">Dispatch Control Room</h1>
|
|
|
|
|
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-200">
|
|
|
|
|
A thin, working slice that lets the team submit truck requests, approve them, assign fleet resources, confirm docking, and preserve the event history.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid grid-cols-3 gap-3 text-center">
|
|
|
|
|
<Kpi label="Open queue" value={activeQueue} />
|
|
|
|
|
<Kpi label="Assigned" value={assignedQueue} />
|
|
|
|
|
<Kpi label="Audits" value={auditEvents.length} />
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{notice && (
|
|
|
|
|
<div className={`mb-6 rounded-2xl border px-4 py-3 text-sm font-medium ${notice.type === 'success' ? 'border-emerald-200 bg-emerald-50 text-emerald-800' : notice.type === 'error' ? 'border-red-200 bg-red-50 text-red-800' : 'border-blue-200 bg-blue-50 text-blue-800'}`}>
|
|
|
|
|
{notice.text}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
|
|
|
|
<CardBox className="border-0 shadow-sm">
|
|
|
|
|
<div className="mb-5 flex items-center justify-between gap-3">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-bold uppercase tracking-[0.2em] text-orange-500">Intake</p>
|
|
|
|
|
<h2 className="text-2xl font-black text-slate-900 dark:text-white">New truck request</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<span className="rounded-full bg-slate-100 px-3 py-1 text-xs font-bold text-slate-600 dark:bg-dark-800 dark:text-slate-300">Requester role</span>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<form onSubmit={handleCreateRequest} className="space-y-4">
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Request type
|
|
|
|
|
<select className={fieldClass()} value={form.request_type} onChange={(event) => setForm({ ...form, request_type: event.target.value })}>
|
|
|
|
|
{requestTypes.map((type) => <option key={type.value} value={type.value}>{type.label}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Priority
|
|
|
|
|
<select className={fieldClass()} value={form.priority} onChange={(event) => setForm({ ...form, priority: event.target.value })}>
|
|
|
|
|
<option value="">Standard</option>
|
|
|
|
|
{priorities.map((priority) => <option key={priority.id} value={priority.id}>{optionLabel(priority, ['priority_name', 'priority_code'])}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Origin
|
|
|
|
|
<input className={fieldClass()} value={form.origin_location} onChange={(event) => setForm({ ...form, origin_location: event.target.value })} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Destination <span className="text-orange-500">*</span>
|
|
|
|
|
<input className={fieldClass()} placeholder="Hub / mall / city route" value={form.destination_location} onChange={(event) => setForm({ ...form, destination_location: event.target.value })} />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Pickup window start
|
|
|
|
|
<input type="datetime-local" className={fieldClass()} value={form.requested_pickup_window_start} onChange={(event) => setForm({ ...form, requested_pickup_window_start: event.target.value })} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Pickup window end
|
|
|
|
|
<input type="datetime-local" className={fieldClass()} value={form.requested_pickup_window_end} onChange={(event) => setForm({ ...form, requested_pickup_window_end: event.target.value })} />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-3">
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Packages <span className="text-orange-500">*</span>
|
|
|
|
|
<input type="number" min="1" className={fieldClass()} value={form.estimated_packages_count} onChange={(event) => setForm({ ...form, estimated_packages_count: event.target.value })} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Weight kg
|
|
|
|
|
<input type="number" min="0" className={fieldClass()} value={form.estimated_weight_kg} onChange={(event) => setForm({ ...form, estimated_weight_kg: event.target.value })} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Facility
|
|
|
|
|
<select className={fieldClass()} value={form.facility} onChange={(event) => setForm({ ...form, facility: event.target.value })}>
|
|
|
|
|
<option value="">Default facility</option>
|
|
|
|
|
{facilities.map((facility) => <option key={facility.id} value={facility.id}>{optionLabel(facility, ['facility_name', 'facility_code'])}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<label className="flex items-center gap-3 rounded-2xl bg-orange-50 px-4 py-3 text-sm font-bold text-orange-900 dark:bg-orange-400/10 dark:text-orange-100">
|
|
|
|
|
<input type="checkbox" className="h-5 w-5 rounded border-orange-300 text-orange-600 focus:ring-orange-500" checked={form.requires_dock} onChange={(event) => setForm({ ...form, requires_dock: event.target.checked })} />
|
|
|
|
|
Requires controlled dock confirmation
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<label className="space-y-2 text-sm font-bold text-slate-700 dark:text-slate-200">
|
|
|
|
|
Dispatch notes
|
|
|
|
|
<textarea className={`${fieldClass()} min-h-24`} value={form.remarks} onChange={(event) => setForm({ ...form, remarks: event.target.value })} placeholder="Special loading instructions, SLA notes, or risk flags" />
|
|
|
|
|
</label>
|
|
|
|
|
|
|
|
|
|
<BaseButton type="submit" color="info" label={submitting ? 'Submitting...' : 'Submit truck request'} disabled={submitting || !permissions.canCreateRequest} className="w-full border-[#EE4D2D] bg-[#EE4D2D] py-3 hover:border-[#d83f22] hover:bg-[#d83f22]" />
|
|
|
|
|
{!permissions.canCreateRequest && <p className="text-center text-xs text-slate-500">Your role can view the queue but cannot create requests.</p>}
|
|
|
|
|
</form>
|
|
|
|
|
</CardBox>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<CardBox className="border-0 shadow-sm">
|
|
|
|
|
<div className="mb-5 flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-bold uppercase tracking-[0.2em] text-[#00B8A9]">Realtime queue</p>
|
|
|
|
|
<h2 className="text-2xl font-black text-slate-900 dark:text-white">Requests needing action</h2>
|
|
|
|
|
</div>
|
|
|
|
|
<BaseButton color="whiteDark" label={loading ? 'Loading...' : 'Refresh'} small onClick={loadWorkspace} disabled={loading} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{requests.length === 0 ? (
|
|
|
|
|
<EmptyState title="No truck requests yet" text="Submit the first request to start the approval and assignment workflow." />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{requests.slice(0, 8).map((request) => (
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
key={request.id}
|
|
|
|
|
onClick={() => setSelectedId(request.id)}
|
|
|
|
|
className={`w-full rounded-2xl border p-4 text-left transition hover:-translate-y-0.5 hover:shadow-md ${selectedId === request.id ? 'border-orange-300 bg-orange-50 dark:bg-orange-400/10' : 'border-slate-100 bg-white dark:border-dark-700 dark:bg-dark-900'}`}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<span className="font-black text-slate-900 dark:text-white">{requestReference(request)}</span>
|
|
|
|
|
<StatusBadge status={request.status} />
|
|
|
|
|
</div>
|
|
|
|
|
<p className="mt-1 text-sm text-slate-500">{request.origin_location || 'Origin'} → {request.destination_location || 'Destination'}</p>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="text-sm font-semibold text-slate-500">
|
|
|
|
|
{request.estimated_packages_count || 0} packages · {humanize(request.request_type)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardBox>
|
|
|
|
|
|
|
|
|
|
<CardBox className="border-0 shadow-sm">
|
|
|
|
|
<div className="mb-5 flex items-center justify-between">
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-bold uppercase tracking-[0.2em] text-orange-500">Detail</p>
|
|
|
|
|
<h2 className="text-2xl font-black text-slate-900 dark:text-white">Approval → assignment → dock</h2>
|
|
|
|
|
</div>
|
|
|
|
|
{selected && <StatusBadge status={selected.status} />}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{!selected ? (
|
|
|
|
|
<EmptyState title="Select a request" text="Choose a queue item to review details and complete controlled role actions." />
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-4">
|
|
|
|
|
<DetailPill label="Request" value={requestReference(selected)} />
|
|
|
|
|
<DetailPill label="Type" value={humanize(selected.request_type)} />
|
|
|
|
|
<DetailPill label="Packages" value={`${selected.estimated_packages_count || 0}`} />
|
|
|
|
|
<DetailPill label="Dock needed" value={selected.requires_dock ? 'Yes' : 'No'} />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="rounded-3xl border border-slate-100 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800">
|
|
|
|
|
<p className="text-sm font-bold text-slate-500">Route</p>
|
|
|
|
|
<p className="mt-2 text-lg font-black text-slate-900 dark:text-white">{selected.origin_location || 'Origin'} → {selected.destination_location || 'Destination'}</p>
|
|
|
|
|
<p className="mt-2 text-sm text-slate-500">{selected.remarks || 'No dispatch notes provided.'}</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="grid gap-4 lg:grid-cols-3">
|
|
|
|
|
<ActionCard
|
|
|
|
|
iconPath={icon.mdiCheckDecagram}
|
|
|
|
|
title="1. Supervisor approval"
|
|
|
|
|
text="Record the approval decision and move the request into assignable status."
|
|
|
|
|
locked={!permissions.canApprove}
|
|
|
|
|
>
|
|
|
|
|
<BaseButton color="success" label={submitting ? 'Working...' : 'Approve request'} onClick={handleApprove} disabled={submitting || !permissions.canApprove || selected.status === 'approved' || selected.status === 'assigned' || selected.status === 'docked'} className="w-full" />
|
|
|
|
|
</ActionCard>
|
|
|
|
|
|
|
|
|
|
<ActionCard
|
|
|
|
|
iconPath={icon.mdiTruckFast}
|
|
|
|
|
title="2. Dispatch assignment"
|
|
|
|
|
text="Choose a truck, driver, and dock for the approved movement."
|
|
|
|
|
locked={!permissions.canAssign}
|
|
|
|
|
>
|
|
|
|
|
<form onSubmit={handleAssign} className="space-y-3">
|
|
|
|
|
<select className={fieldClass()} value={assignment.truck} onChange={(event) => setAssignment({ ...assignment, truck: event.target.value })}>
|
|
|
|
|
<option value="">Select truck</option>
|
|
|
|
|
{trucks.map((truck) => <option key={truck.id} value={truck.id}>{optionLabel(truck, ['truck_number', 'plate_number', 'truck_type'])}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<select className={fieldClass()} value={assignment.driver} onChange={(event) => setAssignment({ ...assignment, driver: event.target.value })}>
|
|
|
|
|
<option value="">Select driver</option>
|
|
|
|
|
{drivers.map((driver) => <option key={driver.id} value={driver.id}>{optionLabel(driver, ['driver_name', 'license_number', 'phone_number'])}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<select className={fieldClass()} value={assignment.dock} onChange={(event) => setAssignment({ ...assignment, dock: event.target.value })}>
|
|
|
|
|
<option value="">Select dock</option>
|
|
|
|
|
{docks.map((dock) => <option key={dock.id} value={dock.id}>{optionLabel(dock, ['dock_name', 'dock_code', 'status'])}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
|
|
|
<input type="datetime-local" className={fieldClass()} value={assignment.scheduled_arrival_at} onChange={(event) => setAssignment({ ...assignment, scheduled_arrival_at: event.target.value })} />
|
|
|
|
|
<input type="datetime-local" className={fieldClass()} value={assignment.scheduled_departure_at} onChange={(event) => setAssignment({ ...assignment, scheduled_departure_at: event.target.value })} />
|
|
|
|
|
</div>
|
|
|
|
|
<BaseButton type="submit" color="info" label="Assign truck" disabled={submitting || !permissions.canAssign || !['approved', 'scheduled', 'submitted'].includes(selected.status || '')} className="w-full" />
|
|
|
|
|
</form>
|
|
|
|
|
</ActionCard>
|
|
|
|
|
|
|
|
|
|
<ActionCard
|
|
|
|
|
iconPath={icon.mdiClipboardCheck}
|
|
|
|
|
title="3. Dock confirmation"
|
|
|
|
|
text="Confirm dock-in against the active assignment and update live status."
|
|
|
|
|
locked={!permissions.canConfirmDock}
|
|
|
|
|
>
|
|
|
|
|
<div className="mb-3 rounded-2xl bg-slate-50 p-3 text-sm text-slate-600 dark:bg-dark-800 dark:text-slate-300">
|
|
|
|
|
{selectedAssignment?.assignment_code ? `Active assignment: ${selectedAssignment.assignment_code}` : 'No assignment has been created yet.'}
|
|
|
|
|
</div>
|
|
|
|
|
<BaseButton color="warning" label="Confirm dock-in" onClick={handleConfirmDock} disabled={submitting || !permissions.canConfirmDock || !selectedAssignment?.id || selected.status === 'docked'} className="w-full" />
|
|
|
|
|
</ActionCard>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="rounded-3xl border border-slate-100 p-4 dark:border-dark-700">
|
|
|
|
|
<h3 className="mb-4 text-lg font-black text-slate-900 dark:text-white">Request event history</h3>
|
|
|
|
|
{selected.realtime_updates_truck_request?.length ? (
|
|
|
|
|
<div className="space-y-3">
|
|
|
|
|
{selected.realtime_updates_truck_request.map((update) => (
|
|
|
|
|
<TimelineItem key={update.id} title={humanize(update.update_type)} text={update.message} date={update.reported_at} />
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<EmptyState title="No updates yet" text="Workflow actions will appear here as realtime updates." compact />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</CardBox>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<CardBox className="mt-6 border-0 shadow-sm">
|
|
|
|
|
<div className="mb-5 flex items-center gap-3">
|
|
|
|
|
<span className="flex h-11 w-11 items-center justify-center rounded-2xl bg-slate-900 text-white"><BaseIcon path={icon.mdiHistory} size="22" /></span>
|
|
|
|
|
<div>
|
|
|
|
|
<p className="text-sm font-bold uppercase tracking-[0.2em] text-slate-500">Audit history</p>
|
|
|
|
|
<h2 className="text-2xl font-black text-slate-900 dark:text-white">Latest auditable events</h2>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{auditEvents.length ? (
|
|
|
|
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
|
|
|
|
{auditEvents.map((event) => (
|
|
|
|
|
<div key={event.id} className="rounded-3xl border border-slate-100 bg-slate-50 p-4 dark:border-dark-700 dark:bg-dark-800">
|
|
|
|
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-orange-500">{humanize(event.action)}</p>
|
|
|
|
|
<p className="mt-2 font-black text-slate-900 dark:text-white">{event.summary || event.entity_reference}</p>
|
|
|
|
|
<p className="mt-2 text-xs text-slate-500">{formatDate(event.event_at || event.createdAt)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<EmptyState title="Audit trail is ready" text="New workflow events will be logged here when actions are completed." compact />
|
|
|
|
|
)}
|
|
|
|
|
</CardBox>
|
|
|
|
|
</SectionMain>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function Kpi({ label, value }: { label: string; value: number }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="min-w-24 rounded-3xl bg-white/10 p-4 ring-1 ring-white/15">
|
|
|
|
|
<div className="text-3xl font-black">{value}</div>
|
|
|
|
|
<div className="mt-1 text-xs font-semibold uppercase tracking-[0.2em] text-slate-300">{label}</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function StatusBadge({ status }: { status?: string }) {
|
|
|
|
|
const tone = statusTone[status || ''] || 'bg-slate-100 text-slate-700';
|
|
|
|
|
return <span className={`rounded-full px-3 py-1 text-xs font-black uppercase tracking-wide ${tone}`}>{humanize(status)}</span>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function DetailPill({ label, value }: { label: string; value: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-3xl bg-slate-50 p-4 dark:bg-dark-800">
|
|
|
|
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-slate-500">{label}</p>
|
|
|
|
|
<p className="mt-2 font-black text-slate-900 dark:text-white">{value}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ActionCard({ iconPath, title, text, locked, children }: { iconPath: string; title: string; text: string; locked: boolean; children: React.ReactNode }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="rounded-3xl border border-slate-100 bg-white p-4 dark:border-dark-700 dark:bg-dark-900">
|
|
|
|
|
<div className="mb-4 flex items-start gap-3">
|
|
|
|
|
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl text-white" style={{ backgroundColor: locked ? '#94a3b8' : brand.orange }}>
|
|
|
|
|
<BaseIcon path={iconPath} size="20" />
|
|
|
|
|
</span>
|
|
|
|
|
<div>
|
|
|
|
|
<h3 className="font-black text-slate-900 dark:text-white">{title}</h3>
|
|
|
|
|
<p className="mt-1 text-sm text-slate-500">{text}</p>
|
|
|
|
|
{locked && <p className="mt-2 text-xs font-bold text-slate-400">Locked by role permissions</p>}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
{children}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function TimelineItem({ title, text, date }: { title: string; text?: string; date?: string }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="flex gap-3">
|
|
|
|
|
<span className="mt-1 h-3 w-3 rounded-full" style={{ backgroundColor: brand.teal }} />
|
|
|
|
|
<div>
|
|
|
|
|
<p className="font-bold text-slate-900 dark:text-white">{title}</p>
|
|
|
|
|
<p className="text-sm text-slate-500">{text || 'Status update recorded.'}</p>
|
|
|
|
|
<p className="mt-1 text-xs text-slate-400">{formatDate(date)}</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function EmptyState({ title, text, compact = false }: { title: string; text: string; compact?: boolean }) {
|
|
|
|
|
return (
|
|
|
|
|
<div className={`rounded-3xl border border-dashed border-slate-200 bg-slate-50 text-center dark:border-dark-700 dark:bg-dark-800 ${compact ? 'p-5' : 'p-8'}`}>
|
|
|
|
|
<p className="font-black text-slate-900 dark:text-white">{title}</p>
|
|
|
|
|
<p className="mt-2 text-sm text-slate-500">{text}</p>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(value?: string) {
|
|
|
|
|
if (!value) return 'No date';
|
|
|
|
|
return new Intl.DateTimeFormat('en', {
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: '2-digit',
|
|
|
|
|
hour: '2-digit',
|
|
|
|
|
minute: '2-digit',
|
|
|
|
|
}).format(new Date(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function readableError(error: any, fallback: string) {
|
|
|
|
|
if (axios.isAxiosError(error)) {
|
|
|
|
|
return error.response?.data?.message || error.response?.data?.error || error.message || fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return fallback;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DispatchControlPage.getLayout = function getLayout(page: ReactElement) {
|
|
|
|
|
return <LayoutAuthenticated permission="READ_TRUCK_REQUESTS">{page}</LayoutAuthenticated>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default DispatchControlPage;
|