392 lines
15 KiB
JavaScript
392 lines
15 KiB
JavaScript
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
|
||
import dayjs from 'dayjs';
|
||
import {
|
||
Truck, FileText, AlertTriangle, RefreshCw,
|
||
Layers, Activity, Download, Info, ArrowRight
|
||
} from 'lucide-react';
|
||
import { Card } from '@astryxdesign/core/Card';
|
||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||
import { Badge } from '@astryxdesign/core/Badge';
|
||
import { Heading, Text } from '@astryxdesign/core/Text';
|
||
|
||
import Button from '@/components/Button';
|
||
import Panel from '@/components/Panel';
|
||
import PageHeader from '@/components/PageHeader';
|
||
import StatCard from '@/components/StatCard';
|
||
import { getDashboard, getInboundVehicles, getZones, getHubReport, getActivity } from '@/api/hub';
|
||
import { getHubContext } from '@/auth/session';
|
||
|
||
const BRAND = 'var(--color-brand)';
|
||
const DATE_FMT = 'YYYY-MM-DD';
|
||
const RANGE_KEY = 'hub_dashboard_range';
|
||
|
||
const clockTime = (iso) => {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return '';
|
||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||
};
|
||
|
||
const KPI_DEFS = [
|
||
{ key: 'parcels_received_today', label: 'Total Parcels Received', icon: Layers, tone: 'blue', sub: 'Received', ranged: true },
|
||
{ key: 'milers_available', label: 'Available Milers', icon: Activity, tone: 'green', sub: 'Free right now' },
|
||
{ key: 'milers_on_duty', label: 'Milers on Duty', icon: Info, tone: 'cyan', sub: 'Currently working' },
|
||
{ key: 'pending_pickups', label: 'Pending Pickups', icon: FileText, tone: 'orange', sub: 'Awaiting a miler' },
|
||
{ key: 'batches_sent_today', label: 'Batches Sent', icon: Truck, tone: 'purple', sub: 'Dispatched', ranged: true },
|
||
{ key: 'exceptions', label: 'Needs Checking', icon: AlertTriangle, tone: 'red', sub: 'Damaged or unclear', ranged: true }
|
||
];
|
||
|
||
export default function Dashboard() {
|
||
const today = dayjs().format(DATE_FMT);
|
||
const weekAgo = dayjs().subtract(6, 'day').format(DATE_FMT);
|
||
|
||
// eslint-disable-next-line no-unused-vars
|
||
const [range, setRange] = useState(() => {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(RANGE_KEY));
|
||
if (saved?.from && saved?.to && !dayjs(saved.to).isAfter(dayjs(today))) return saved;
|
||
} catch {
|
||
// ignore
|
||
}
|
||
return { from: weekAgo, to: today };
|
||
});
|
||
|
||
useEffect(() => {
|
||
localStorage.setItem(RANGE_KEY, JSON.stringify(range));
|
||
}, [range]);
|
||
|
||
const hub = getHubContext();
|
||
const [kpis, setKpis] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
const [error, setError] = useState('');
|
||
const [incomingVehicles, setIncomingVehicles] = useState([]);
|
||
const [activeRoutes, setActiveRoutes] = useState([]);
|
||
const [recentActivity, setRecentActivity] = useState([]);
|
||
const [exporting, setExporting] = useState(false);
|
||
|
||
const loadRequestId = useRef(0);
|
||
|
||
const loadDashboard = useCallback(async () => {
|
||
const requestId = ++loadRequestId.current;
|
||
setLoading(true);
|
||
setError('');
|
||
try {
|
||
const [dash, vehicles, zones, activity] = await Promise.all([
|
||
getDashboard(range.from, range.to).catch(() => null),
|
||
getInboundVehicles().catch(() => null),
|
||
getZones().catch(() => null),
|
||
getActivity(8).catch(() => null)
|
||
]);
|
||
if (loadRequestId.current !== requestId) return;
|
||
|
||
setKpis(dash?.data || {});
|
||
setIncomingVehicles(
|
||
(vehicles?.data || []).map((v) => ({
|
||
id: v.vehicleno || `Trip ${v.tripsheetid}`,
|
||
origin: v.origin || '—',
|
||
status: v.status || 'On the way',
|
||
progress: v.unloadedpct || 0
|
||
}))
|
||
);
|
||
setActiveRoutes(
|
||
(zones?.data || []).map((z) => ({
|
||
zone: z.zonename ? `${z.zonename} (${z.zone})` : z.zone,
|
||
packages: z.parcels ?? 0,
|
||
riders: z.milers ?? 0,
|
||
status: z.status || 'Active'
|
||
}))
|
||
);
|
||
setRecentActivity(
|
||
(activity?.data || []).map((a) => ({ time: clockTime(a.time), type: a.type, text: a.text }))
|
||
);
|
||
} catch (err) {
|
||
if (loadRequestId.current === requestId) {
|
||
setError(err?.message || 'Could not load dashboard stats.');
|
||
}
|
||
} finally {
|
||
if (loadRequestId.current === requestId) {
|
||
setLoading(false);
|
||
}
|
||
}
|
||
}, [range.from, range.to]);
|
||
|
||
useEffect(() => {
|
||
loadDashboard();
|
||
}, [loadDashboard]);
|
||
|
||
const handleExport = useCallback(async () => {
|
||
if (exporting) return;
|
||
setExporting(true);
|
||
try {
|
||
const [{ downloadHubReport }, res] = await Promise.all([
|
||
import('@/lib/hubReport'),
|
||
getHubReport(range.from, range.to)
|
||
]);
|
||
downloadHubReport(res?.data || {}, { hubName: hub.hubname || 'Hub', from: range.from, to: range.to });
|
||
} catch (err) {
|
||
console.error(err);
|
||
} finally {
|
||
setExporting(false);
|
||
}
|
||
}, [exporting, range.from, range.to, hub.hubname]);
|
||
|
||
const rangeLabel = useMemo(() => {
|
||
if (range.from === today && range.to === today) return 'today';
|
||
if (range.from === range.to) return `on ${dayjs(range.from).format('DD MMM')}`;
|
||
return `${dayjs(range.from).format('DD MMM')} – ${dayjs(range.to).format('DD MMM')}`;
|
||
}, [range, today]);
|
||
|
||
const kpiCards = KPI_DEFS.map((d) => ({
|
||
...d,
|
||
sub: d.ranged ? `${d.sub} ${rangeLabel}` : d.sub,
|
||
value: kpis && kpis[d.key] != null ? Number(kpis[d.key]).toLocaleString('en-IN') : '—'
|
||
}));
|
||
|
||
const sorted = Number(kpis?.parcels_sorted ?? 0);
|
||
const target = Number(kpis?.sorting_target ?? 0);
|
||
const pct = target > 0 ? Math.min(100, Math.round((sorted / target) * 100)) : 0;
|
||
|
||
const getVehicleBadgeVariant = (status) => {
|
||
const s = String(status).toLowerCase();
|
||
if (s.includes('unloading')) return 'positive';
|
||
if (s.includes('way')) return 'info';
|
||
return 'neutral';
|
||
};
|
||
|
||
const getZoneBadgeVariant = (status) => {
|
||
const s = String(status).toLowerCase();
|
||
if (s.includes('active')) return 'positive';
|
||
if (s.includes('need') || s.includes('pending')) return 'warning';
|
||
return 'neutral';
|
||
};
|
||
|
||
const trucksColumns = useMemo(() => [
|
||
{
|
||
key: 'id',
|
||
header: <div style={{ paddingLeft: '24px' }}>Truck</div>,
|
||
width: proportional(1),
|
||
renderCell: (c) => (
|
||
<div style={{ paddingLeft: '24px' }}>
|
||
<Text type="body" weight="semibold">{c.id}</Text>
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: 'origin',
|
||
header: 'Coming From',
|
||
width: proportional(1.5),
|
||
renderCell: (c) => <Text type="body">{c.origin}</Text>
|
||
},
|
||
{
|
||
key: 'status',
|
||
header: 'Status',
|
||
width: pixel(140),
|
||
renderCell: (c) => <Badge variant={getVehicleBadgeVariant(c.status)} label={c.status} />
|
||
},
|
||
{
|
||
key: 'progress',
|
||
header: 'Unloaded',
|
||
width: pixel(120),
|
||
renderCell: (c) => c.progress > 0 ? (
|
||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||
<div style={{ background: 'var(--color-background-muted)', height: '6px', borderRadius: 'var(--radius-full)', flex: 1, overflow: 'hidden' }}>
|
||
<div style={{ background: BRAND, width: `${c.progress}%`, height: '100%' }} />
|
||
</div>
|
||
<Text type="supporting" weight="bold">{c.progress}%</Text>
|
||
</div>
|
||
) : <Text type="supporting" color="disabled">—</Text>
|
||
}
|
||
], []);
|
||
|
||
const zonesColumns = useMemo(() => [
|
||
{
|
||
key: 'zone',
|
||
header: <div style={{ paddingLeft: '24px' }}>Area</div>,
|
||
width: proportional(2),
|
||
renderCell: (c) => (
|
||
<div style={{ paddingLeft: '24px' }}>
|
||
<Text type="body" weight="semibold">{c.zone}</Text>
|
||
</div>
|
||
)
|
||
},
|
||
{
|
||
key: 'packages',
|
||
header: 'Parcels',
|
||
width: pixel(100),
|
||
align: 'end',
|
||
renderCell: (c) => <Text type="body" hasTabularNumbers>{c.packages}</Text>
|
||
},
|
||
{
|
||
key: 'riders',
|
||
header: 'Milers',
|
||
width: pixel(100),
|
||
align: 'end',
|
||
renderCell: (c) => <Text type="body" hasTabularNumbers>{c.riders}</Text>
|
||
},
|
||
{
|
||
key: 'status',
|
||
header: 'Status',
|
||
width: pixel(140),
|
||
renderCell: (c) => <Badge variant={getZoneBadgeVariant(c.status)} label={c.status} />
|
||
}
|
||
], []);
|
||
|
||
return (
|
||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||
<PageHeader
|
||
title={hub.hubname || 'Doormile Hub'}
|
||
subtitle={`Live hub snapshot${hub.city ? ` · ${hub.city}` : ''}`}
|
||
action={
|
||
<div style={{ display: 'flex', gap: '12px', alignItems: 'center' }}>
|
||
<Button variant="outline" size="icon" onClick={loadDashboard} disabled={loading} aria-label="Refresh">
|
||
<RefreshCw size={16} className={loading ? 'spin' : ''} />
|
||
</Button>
|
||
<Button onClick={handleExport} disabled={exporting}>
|
||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||
<Download size={16} style={{ marginRight: '8px' }} />
|
||
{exporting ? 'Exporting…' : 'Export'}
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
}
|
||
/>
|
||
|
||
{error && (
|
||
<div style={{ background: 'var(--color-background-red)', border: '1px solid var(--color-border-red)', color: 'var(--color-text-red)', padding: '16px', borderRadius: 'var(--radius-container)', marginBottom: '24px' }}>
|
||
<Text type="body" weight="semibold" style={{ color: 'var(--color-text-red)' }}>{error}</Text>
|
||
</div>
|
||
)}
|
||
|
||
{/* Overview Grid */}
|
||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '16px', marginBottom: '32px' }}>
|
||
{kpiCards.map((s) => (
|
||
<StatCard key={s.label} icon={s.icon} label={s.label} value={s.value} sub={s.sub} tone={s.tone} loading={loading} hover={false} />
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '24px' }} className="responsive-row">
|
||
|
||
{/* Left Column: Progress & Trucks */}
|
||
<div style={{ flex: '1 1 600px' }} className="col-span-8">
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||
|
||
{/* Progress Card */}
|
||
<Card padding={6}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: '16px' }}>
|
||
<Heading level={5}>Sorting Progress</Heading>
|
||
{target > 0 && <Text type="supporting" color="secondary">{sorted.toLocaleString()} done · {pct}%</Text>}
|
||
</div>
|
||
<div style={{ marginBottom: '12px' }}>
|
||
<Text type="body" weight="semibold">
|
||
{target > 0 ? `Today's target — ${target.toLocaleString()} parcels` : `Sorted today — ${sorted.toLocaleString()} parcels`}
|
||
</Text>
|
||
</div>
|
||
<div style={{ background: 'var(--color-background-muted)', height: '10px', borderRadius: 'var(--radius-full)', overflow: 'hidden' }}>
|
||
<div style={{ background: BRAND, width: `${pct}%`, height: '100%', transition: 'width 0.5s ease' }} />
|
||
</div>
|
||
</Card>
|
||
|
||
{/* Trucks Panel */}
|
||
<Panel title="Trucks Arriving">
|
||
<div style={{ paddingTop: '8px', paddingBottom: '8px', overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||
<Table
|
||
data={incomingVehicles}
|
||
columns={trucksColumns}
|
||
idKey="id"
|
||
density="standard"
|
||
dividers="rows"
|
||
hasHover
|
||
isStriped
|
||
/>
|
||
{incomingVehicles.length === 0 && !loading && (
|
||
<div style={{ padding: '32px', textAlign: 'center' }}>
|
||
<Text type="supporting" color="secondary">No trucks arriving right now.</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Panel>
|
||
|
||
{/* Right Column: Active Routes */}
|
||
<Panel title="Delivery Areas Today">
|
||
<div style={{ paddingTop: '8px', paddingBottom: '8px', overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
|
||
<Table
|
||
data={activeRoutes}
|
||
columns={zonesColumns}
|
||
idKey="zone"
|
||
density="standard"
|
||
dividers="rows"
|
||
hasHover
|
||
isStriped
|
||
/>
|
||
{activeRoutes.length === 0 && !loading && (
|
||
<div style={{ padding: '32px', textAlign: 'center' }}>
|
||
<Text type="supporting" color="secondary">No active delivery areas.</Text>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</Panel>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{/* Right Column: Activity Feed */}
|
||
<div style={{ flex: '1 1 300px' }} className="col-span-4">
|
||
<Panel title="Recent Activity">
|
||
<div style={{ padding: '24px' }}>
|
||
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
|
||
{recentActivity.length === 0 && !loading && (
|
||
<Text type="supporting" color="secondary">No recent activity.</Text>
|
||
)}
|
||
{recentActivity.map((act, i) => (
|
||
<div key={i} style={{ display: 'flex', gap: '12px', alignItems: 'flex-start' }}>
|
||
<div style={{ width: '8px', height: '8px', borderRadius: '50%', background: act.type === 'inbound' ? BRAND : act.type === 'exception' ? 'var(--color-icon-red)' : 'var(--color-icon-green)', marginTop: '6px' }} />
|
||
<div>
|
||
<Text type="body" weight="medium">{act.text}</Text>
|
||
<Text type="supporting" color="secondary" style={{ marginTop: '2px' }}>{act.time}</Text>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div style={{ background: 'var(--color-brand-muted)', border: '1px dashed color-mix(in srgb, var(--color-brand) 40%, transparent)', padding: '20px', borderRadius: 'var(--radius-container)', marginTop: '32px' }}>
|
||
<div style={{ fontWeight: 'bold', color: BRAND, marginBottom: '8px' }}>Not sure where a parcel goes?</div>
|
||
<Text type="supporting" color="secondary" style={{ display: 'block', marginBottom: '16px' }}>Scan it and we'll tell you exactly what to do next.</Text>
|
||
<Button style={{ width: '100%', justifyContent: 'center' }} as="a" href="/routing">
|
||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||
Where Does It Go? <ArrowRight size={16} style={{ marginLeft: '8px' }} />
|
||
</span>
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</Panel>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<style>{`
|
||
.spin { animation: spin 1s linear infinite; }
|
||
@keyframes spin { 100% { transform: rotate(360deg); } }
|
||
|
||
@media (min-width: 1024px) {
|
||
.responsive-row {
|
||
grid-template-columns: repeat(12, 1fr) !important;
|
||
}
|
||
.col-span-8 {
|
||
grid-column: span 8 !important;
|
||
}
|
||
.col-span-4 {
|
||
grid-column: span 4 !important;
|
||
}
|
||
}
|
||
@media (max-width: 1023px) {
|
||
.responsive-row {
|
||
grid-template-columns: 1fr !important;
|
||
}
|
||
.col-span-8, .col-span-4 {
|
||
grid-column: span 1 !important;
|
||
}
|
||
}
|
||
`}</style>
|
||
</div>
|
||
);
|
||
}
|