This commit is contained in:
Flatlogic Bot 2026-07-25 16:18:51 +00:00
parent 564e6f8ad9
commit 7b407ade49
5 changed files with 919 additions and 148 deletions

View File

@ -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'

View File

@ -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'

View File

@ -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',

View File

@ -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;

View File

@ -1,161 +1,123 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import type { ReactElement } from 'react';
import Head from 'next/head';
import Link from 'next/link';
import BaseButton from '../components/BaseButton';
import CardBox from '../components/CardBox';
import SectionFullScreen from '../components/SectionFullScreen';
import LayoutGuest from '../layouts/Guest';
import BaseDivider from '../components/BaseDivider';
import BaseButtons from '../components/BaseButtons';
import { getPageTitle } from '../config';
import { useAppSelector } from '../stores/hooks';
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
const workflowSteps = [
'Controlled truck request intake',
'Supervisor approval checkpoint',
'Dispatcher truck + dock assignment',
'Dock confirmation and live status trail',
];
const metrics = [
{ label: 'Queue visibility', value: 'Live' },
{ label: 'Audit coverage', value: '100%' },
{ label: 'Roles supported', value: '4' },
];
export default function Starter() {
const [illustrationImage, setIllustrationImage] = useState({
src: undefined,
photographer: undefined,
photographer_url: undefined,
})
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
const [contentType, setContentType] = useState('video');
const [contentPosition, setContentPosition] = useState('left');
const textColor = useAppSelector((state) => state.style.linkColor);
const title = 'App Preview'
// Fetch Pexels image/video
useEffect(() => {
async function fetchData() {
const image = await getPexelsImage();
const video = await getPexelsVideo();
setIllustrationImage(image);
setIllustrationVideo(video);
}
fetchData();
}, []);
const imageBlock = (image) => (
<div
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
style={{
backgroundImage: `${
image
? `url(${image?.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}}
>
<div className='flex justify-center w-full bg-blue-300/20'>
<a
className='text-[8px]'
href={image?.photographer_url}
target='_blank'
rel='noreferrer'
>
Photo by {image?.photographer} on Pexels
</a>
</div>
</div>
);
const videoBlock = (video) => {
if (video?.video_files?.length > 0) {
return (
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
<video
className='absolute top-0 left-0 w-full h-full object-cover'
autoPlay
loop
muted
>
<source src={video?.video_files[0]?.link} type='video/mp4'/>
Your browser does not support the video tag.
</video>
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
<a
className='text-[8px]'
href={video?.user?.url}
target='_blank'
rel='noreferrer'
>
Video by {video.user.name} on Pexels
</a>
</div>
</div>)
}
};
return (
<div
style={
contentPosition === 'background'
? {
backgroundImage: `${
illustrationImage
? `url(${illustrationImage.src?.original})`
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
}`,
backgroundSize: 'cover',
backgroundPosition: 'left center',
backgroundRepeat: 'no-repeat',
}
: {}
}
>
<div className="min-h-screen bg-[#06111f] text-white">
<Head>
<title>{getPageTitle('Starter Page')}</title>
<title>{getPageTitle('Shopee Sorting Dispatch')}</title>
<meta
name="description"
content="Role-aware truck request portal for Shopee Sorting Facility Dispatch."
/>
</Head>
<SectionFullScreen bg='violet'>
<div
className={`flex ${
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
} min-h-screen w-full`}
>
{contentType === 'image' && contentPosition !== 'background'
? imageBlock(illustrationImage)
: null}
{contentType === 'video' && contentPosition !== 'background'
? videoBlock(illustrationVideo)
: null}
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
<div className="space-y-3">
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
<p className='text-center text-gray-500'>For guides and documentation please check
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
</div>
<BaseButtons>
<BaseButton
href='/login'
label='Login'
color='info'
className='w-full'
/>
</BaseButtons>
</CardBox>
<header className="sticky top-0 z-20 border-b border-white/10 bg-[#06111f]/85 backdrop-blur-xl">
<div className="mx-auto flex max-w-7xl items-center justify-between px-6 py-4">
<Link href="/" className="flex items-center gap-3">
<span className="flex h-10 w-10 items-center justify-center rounded-2xl bg-[#EE4D2D] font-black shadow-lg shadow-orange-500/30">
S
</span>
<span>
<span className="block text-sm font-semibold uppercase tracking-[0.35em] text-orange-200">
Shopee
</span>
<span className="block text-lg font-black leading-5">Dispatch Portal</span>
</span>
</Link>
<nav className="flex items-center gap-3 text-sm">
<Link href="/login" className="rounded-full px-4 py-2 text-white/80 transition hover:bg-white/10 hover:text-white">
Login
</Link>
<Link href="/dispatch-control" className="hidden rounded-full border border-white/15 px-4 py-2 text-white/80 transition hover:bg-white/10 hover:text-white sm:inline-flex">
Admin interface
</Link>
</nav>
</div>
</div>
</SectionFullScreen>
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
Privacy Policy
</Link>
</div>
</header>
<main>
<section className="relative overflow-hidden">
<div className="absolute -left-32 top-20 h-72 w-72 rounded-full bg-[#EE4D2D]/30 blur-3xl" />
<div className="absolute right-0 top-0 h-96 w-96 rounded-full bg-[#00B8A9]/20 blur-3xl" />
<div className="mx-auto grid min-h-[calc(100vh-74px)] max-w-7xl items-center gap-12 px-6 py-16 lg:grid-cols-[1.05fr_0.95fr]">
<div className="relative z-10">
<div className="mb-6 inline-flex items-center gap-2 rounded-full border border-orange-300/25 bg-orange-400/10 px-4 py-2 text-sm font-semibold text-orange-100">
Replacing Google Sheets with controlled dispatch flow
</div>
<h1 className="max-w-4xl text-5xl font-black leading-tight tracking-tight md:text-7xl">
Role-aware truck requests for a faster sorting facility dispatch desk.
</h1>
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-300">
Submit, approve, assign, dock, and audit every truck movement from one modern control room built for Shopee Sorting Facility operations.
</p>
<div className="mt-9 flex flex-col gap-3 sm:flex-row">
<BaseButton href="/dispatch-control" color="info" label="Open dispatch control" className="border-[#EE4D2D] bg-[#EE4D2D] px-6 py-3 text-base hover:border-[#d83f22] hover:bg-[#d83f22]" />
<BaseButton href="/login" color="white" label="Login to admin" className="px-6 py-3 text-base" />
</div>
<div className="mt-10 grid max-w-xl grid-cols-3 gap-3">
{metrics.map((metric) => (
<div key={metric.label} className="rounded-3xl border border-white/10 bg-white/[0.06] p-4 backdrop-blur">
<div className="text-2xl font-black text-white">{metric.value}</div>
<div className="mt-1 text-xs uppercase tracking-[0.2em] text-slate-400">{metric.label}</div>
</div>
))}
</div>
</div>
<div className="relative z-10 rounded-[2rem] border border-white/10 bg-white/[0.07] p-4 shadow-2xl shadow-black/30 backdrop-blur-xl">
<div className="rounded-[1.5rem] bg-[#0b1829] p-5">
<div className="mb-5 flex items-center justify-between">
<div>
<p className="text-sm uppercase tracking-[0.3em] text-[#00B8A9]">Control room</p>
<h2 className="text-2xl font-black">Dispatch workflow</h2>
</div>
<span className="rounded-full bg-emerald-400/15 px-3 py-1 text-sm font-bold text-emerald-200">Online</span>
</div>
<div className="space-y-3">
{workflowSteps.map((step, index) => (
<div key={step} className="flex items-center gap-4 rounded-2xl border border-white/8 bg-white/[0.04] p-4">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-white text-sm font-black text-[#06111f]">
{index + 1}
</span>
<div className="flex-1">
<p className="font-bold">{step}</p>
<p className="text-sm text-slate-400">Audited status changes and role permission checks.</p>
</div>
</div>
))}
</div>
<div className="mt-5 rounded-2xl bg-gradient-to-r from-[#EE4D2D] to-[#FF8A3D] p-5 text-white">
<p className="text-sm font-semibold uppercase tracking-[0.25em] text-white/75">First MVP slice</p>
<p className="mt-2 text-xl font-black">Create approve assign confirm dock</p>
</div>
</div>
</div>
</div>
</section>
</main>
<footer className="border-t border-white/10 px-6 py-6 text-center text-sm text-slate-400">
© 2026 Shopee Sorting Dispatch. <Link className="text-orange-200 hover:text-white" href="/privacy-policy/">Privacy Policy</Link>
</footer>
</div>
);
}
@ -163,4 +125,3 @@ export default function Starter() {
Starter.getLayout = function getLayout(page: ReactElement) {
return <LayoutGuest>{page}</LayoutGuest>;
};