ver1
This commit is contained in:
parent
564e6f8ad9
commit
7b407ade49
@ -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'
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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',
|
||||
|
||||
806
frontend/src/pages/dispatch-control.tsx
Normal file
806
frontend/src/pages/dispatch-control.tsx
Normal 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;
|
||||
@ -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>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user