Files
doormile_hub_console/src/pages/Dashboard.jsx

392 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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&apos;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>
);
}