Finalize CRM transformation, mobile responsiveness, and Qdrant integration
This commit is contained in:
@@ -1,124 +1,260 @@
|
||||
import { Grid, Card, CardContent, Stack, Typography, Box, Button, Divider, Table, TableBody, TableCell, TableHead, TableRow, Avatar, MenuItem, TextField } from '@mui/material';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Grid, Card, Box, Stack, Typography, Button, Divider, LinearProgress, CircularProgress, Alert,
|
||||
Table, TableBody, TableCell, TableHead, TableRow, TableContainer
|
||||
} from '@mui/material';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import TwoWheelerOutlinedIcon from '@mui/icons-material/TwoWheelerOutlined';
|
||||
import CurrencyRupeeIcon from '@mui/icons-material/CurrencyRupee';
|
||||
import FileDownloadOutlinedIcon from '@mui/icons-material/FileDownloadOutlined';
|
||||
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||
import HistoryOutlinedIcon from '@mui/icons-material/HistoryOutlined';
|
||||
import DonutLargeOutlinedIcon from '@mui/icons-material/DonutLargeOutlined';
|
||||
import CategoryOutlinedIcon from '@mui/icons-material/CategoryOutlined';
|
||||
import GroupsOutlinedIcon from '@mui/icons-material/GroupsOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import MainCard from '@/components/MainCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import AreaChart from '@/components/charts/AreaChart';
|
||||
import DonutChart from '@/components/charts/DonutChart';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import { ordersTrend, statusBreakdown, orders, riders } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import { fetchPoints, COLLECTIONS } from '@/utils/qdrant';
|
||||
import bgImage from '@/assets/premium_logistics_bg.png';
|
||||
|
||||
const titleCase = (s) =>
|
||||
String(s || '').replace(/[_-]+/g, ' ').replace(/([a-z\d])([A-Z])/g, '$1 $2').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
|
||||
|
||||
const STATUS_COLOR = { newclient: '#00A2AE', contacted: '#FFBF00', onboarded: '#00A854', lost: '#F04134' };
|
||||
const statusColor = (s) => STATUS_COLOR[String(s || '').toLowerCase()] || '#8C8C8C';
|
||||
const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8C8C', '#D6515C'];
|
||||
|
||||
const generateLogicalId = (id) => {
|
||||
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
||||
};
|
||||
|
||||
function toClient(point) {
|
||||
const p = point.payload || {};
|
||||
return {
|
||||
id: point.id,
|
||||
logicalId: generateLogicalId(point.id),
|
||||
name: p.name || '—',
|
||||
businessType: p.businessType || '',
|
||||
city: p.city || '',
|
||||
businessState: p.businessState || '',
|
||||
status: p.status || 'unknown',
|
||||
parcelVolume: Number(p.parcelVolume) || 0,
|
||||
activeContracts: Number(p.activeContracts) || 0,
|
||||
lastUpdated: p.lastUpdated || ''
|
||||
};
|
||||
}
|
||||
function toUser(point) {
|
||||
const p = point.payload || {};
|
||||
return { id: point.id, name: p.name || '—', email: p.email || '', role: p.role || 'unknown' };
|
||||
}
|
||||
|
||||
// Card with a tinted icon header band.
|
||||
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
||||
return (
|
||||
<Card sx={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: 4,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.03)',
|
||||
border: '1px solid rgba(0,0,0,0.04)'
|
||||
}}>
|
||||
<Stack
|
||||
direction="row" spacing={1.5} alignItems="center"
|
||||
sx={{
|
||||
px: 3, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', bgcolor: `${color}.lighter`, color: `${color}.main`, boxShadow: 'inset 0 0 0 1px rgba(0,0,0,0.05)' }}>
|
||||
<Icon fontSize="small" />
|
||||
</Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.800', flexGrow: 1, letterSpacing: '-0.3px' }}>{title}</Typography>
|
||||
{action}
|
||||
</Stack>
|
||||
<Box sx={{ p: noPadding ? 0 : 3, flexGrow: 1 }}>{children}</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const [clients, setClients] = useState([]);
|
||||
const [team, setTeam] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([fetchPoints(COLLECTIONS.clients), fetchPoints(COLLECTIONS.teamUsers)])
|
||||
.then(([cs, us]) => {
|
||||
setClients(cs.map(toClient));
|
||||
setTeam(us.map(toUser));
|
||||
})
|
||||
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: clients.length,
|
||||
newCount: clients.filter((c) => c.status === 'newClient').length,
|
||||
parcels: clients.reduce((s, c) => s + c.parcelVolume, 0),
|
||||
contracts: clients.reduce((s, c) => s + c.activeContracts, 0)
|
||||
}), [clients]);
|
||||
|
||||
const statusData = useMemo(() => {
|
||||
const m = {};
|
||||
clients.forEach((c) => { m[c.status] = (m[c.status] || 0) + 1; });
|
||||
return Object.entries(m).sort((a, b) => b[1] - a[1]).map(([status, value]) => ({ label: titleCase(status), value, color: statusColor(status) }));
|
||||
}, [clients]);
|
||||
|
||||
const byType = useMemo(() => {
|
||||
const m = {};
|
||||
clients.forEach((c) => { const t = c.businessType || 'other'; m[t] = (m[t] || 0) + 1; });
|
||||
return Object.entries(m).sort((a, b) => b[1] - a[1]);
|
||||
}, [clients]);
|
||||
const maxType = byType[0]?.[1] || 1;
|
||||
|
||||
const recent = useMemo(
|
||||
() => [...clients].sort((a, b) => String(b.lastUpdated).localeCompare(String(a.lastUpdated))).slice(0, 6),
|
||||
[clients]
|
||||
);
|
||||
|
||||
const today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}><CircularProgress /></Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
breadcrumbs={[{ label: 'Dashboard' }]}
|
||||
action={
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<TextField select size="small" defaultValue="all" sx={{ minWidth: 150 }}>
|
||||
<MenuItem value="all">All Locations</MenuItem>
|
||||
<MenuItem value="blr">Bengaluru</MenuItem>
|
||||
<MenuItem value="mum">Mumbai</MenuItem>
|
||||
</TextField>
|
||||
<Button variant="outlined" startIcon={<FileDownloadOutlinedIcon />}>Export</Button>
|
||||
</Stack>
|
||||
}
|
||||
action={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>}
|
||||
/>
|
||||
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Total Orders" value="1,402" icon={Inventory2OutlinedIcon} trend={8.4} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Delivered" value="1,330" icon={LocalShippingOutlinedIcon} color="success" trend={6.1} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Active Riders" value="48" icon={TwoWheelerOutlinedIcon} color="info" trend={-2.3} caption="vs last month" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard title="Revenue" value={inr(384200)} icon={CurrencyRupeeIcon} color="warning" trend={11.7} caption="vs last month" /></Grid>
|
||||
{error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||
|
||||
|
||||
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} color="primary" caption="All registered" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<MainCard
|
||||
title="Orders Overview"
|
||||
action={<Stack direction="row" spacing={2}><Legend color="#C01227" label="Orders" /><Legend color="#00A854" label="Delivered" /></Stack>}
|
||||
>
|
||||
<AreaChart
|
||||
labels={ordersTrend.map((d) => d.m)}
|
||||
series={[
|
||||
{ name: 'Orders', color: '#C01227', data: ordersTrend.map((d) => d.orders) },
|
||||
{ name: 'Delivered', color: '#00A854', data: ordersTrend.map((d) => d.delivered) }
|
||||
]}
|
||||
/>
|
||||
</MainCard>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<MainCard title="Order Status">
|
||||
<Box sx={{ py: 2 }}>
|
||||
<DonutChart data={statusBreakdown} centerValue="1,402" centerLabel="Orders" />
|
||||
</Box>
|
||||
</MainCard>
|
||||
<Panel icon={HistoryOutlinedIcon} title="Recent Clients" color="primary" noPadding>
|
||||
{recent.length === 0 ? (
|
||||
<EmptyState title="No clients yet" caption="Add a client to see it here." />
|
||||
) : (
|
||||
<TableContainer>
|
||||
<Table sx={{ minWidth: 600 }}>
|
||||
<TableHead>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell align="right">Parcels</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{recent.map((c) => (
|
||||
<TableRow key={c.id} hover>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={c.name} size={32} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{c.name}</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell><Typography variant="body2">{titleCase(c.businessType) || '—'}</Typography></TableCell>
|
||||
<TableCell><Typography variant="body2">{c.city || '—'}{c.businessState ? `, ${c.businessState}` : ''}</Typography></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{c.parcelVolume.toLocaleString('en-IN')}</TableCell>
|
||||
<TableCell><StatusChip status={c.status} /></TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={7}>
|
||||
<MainCard title="Recent Orders" noPadding>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Order ID</TableCell>
|
||||
<TableCell>Customer</TableCell>
|
||||
<TableCell>Route</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Amount</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{orders.slice(0, 6).map((o) => (
|
||||
<TableRow key={o.id} hover>
|
||||
<TableCell sx={{ fontWeight: 600, color: 'primary.main' }}>{o.id}</TableCell>
|
||||
<TableCell>{o.customer}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="caption" color="text.secondary">{o.pickup} → {o.drop}</Typography>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={o.status} /></TableCell>
|
||||
<TableCell align="right" sx={{ fontWeight: 600 }}>{inr(o.charges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</MainCard>
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Panel icon={DonutLargeOutlinedIcon} title="Clients by Status" color="primary">
|
||||
<Box sx={{ py: 1.5 }}>
|
||||
{statusData.length === 0
|
||||
? <EmptyState title="No data" />
|
||||
: <DonutChart data={statusData} centerValue={stats.total} centerLabel="Clients" />}
|
||||
</Box>
|
||||
</Panel>
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={5}>
|
||||
<MainCard title="Top Riders Today">
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{riders.slice(0, 5).map((r, i) => (
|
||||
<Stack key={r.id} direction="row" spacing={2} alignItems="center" sx={{ py: 1.25 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ width: 18 }}>{i + 1}</Typography>
|
||||
<UserAvatar name={r.name} size={36} />
|
||||
<Box sx={{ flexGrow: 1 }}>
|
||||
<Typography variant="subtitle2">{r.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{r.vehicle} · ⭐ {r.rating}</Typography>
|
||||
|
||||
<Grid item xs={12} lg={8}>
|
||||
<Panel icon={CategoryOutlinedIcon} title="Clients by Business Type" color="primary">
|
||||
{byType.length === 0 ? (
|
||||
<EmptyState title="No data" />
|
||||
) : (
|
||||
<Stack spacing={2.25}>
|
||||
{byType.map(([type, count], i) => (
|
||||
<Box key={type}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center" sx={{ mb: 0.75 }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: BAR_COLORS[i % BAR_COLORS.length] }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{titleCase(type)}</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{count} · {Math.round((count / clients.length) * 100)}%
|
||||
</Typography>
|
||||
</Stack>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={(count / maxType) * 100}
|
||||
sx={{ height: 8, borderRadius: 4, bgcolor: 'grey.100', '& .MuiLinearProgress-bar': { borderRadius: 4, backgroundColor: BAR_COLORS[i % BAR_COLORS.length] } }}
|
||||
/>
|
||||
</Box>
|
||||
<Box sx={{ textAlign: 'right' }}>
|
||||
<Typography variant="subtitle2">{r.deliveries}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">deliveries</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
</MainCard>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} lg={4}>
|
||||
<Panel icon={GroupsOutlinedIcon} title={`App Users · ${team.length}`} color="primary">
|
||||
{team.length === 0 ? (
|
||||
<EmptyState title="No team users" />
|
||||
) : (
|
||||
<Stack divider={<Divider />} spacing={0}>
|
||||
{team.slice(0, 6).map((u) => (
|
||||
<Stack key={u.id} direction="row" spacing={1.5} alignItems="center" sx={{ py: 1.25 }}>
|
||||
<UserAvatar name={u.name} size={36} />
|
||||
<Box sx={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600 }}>{u.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>{u.email}</Typography>
|
||||
</Box>
|
||||
<StatusChip status={u.role} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Legend({ color, label }) {
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<Box sx={{ width: 10, height: 10, borderRadius: '3px', bgcolor: color }} />
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user