@@ -1,24 +1,18 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
Building2,
|
||||
UserPlus,
|
||||
Package,
|
||||
Handshake,
|
||||
History,
|
||||
PieChart,
|
||||
Layers,
|
||||
Users,
|
||||
ArrowRight,
|
||||
Calendar
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@astryxdesign/core/Button';
|
||||
import { Card } from '@astryxdesign/core/Card';
|
||||
import { Table, proportional, pixel } from '@astryxdesign/core/Table';
|
||||
import { Badge } from '@astryxdesign/core/Badge';
|
||||
import { Text, Heading } from '@astryxdesign/core/Text';
|
||||
import { Banner } from '@astryxdesign/core/Banner';
|
||||
Grid, Card, Box, Stack, Typography, Button, Divider, LinearProgress, CircularProgress, Alert,
|
||||
Table, TableBody, TableCell, TableHead, TableRow, TableContainer, useMediaQuery
|
||||
} from '@mui/material';
|
||||
import { useTheme } from '@mui/material/styles';
|
||||
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 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';
|
||||
@@ -36,86 +30,50 @@ const BAR_COLORS = ['#C01227', '#00A2AE', '#00A854', '#FFBF00', '#9E0E20', '#8C8
|
||||
|
||||
function Panel({ icon: Icon, title, action, color = 'primary', noPadding = false, children }) {
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderRadius: '16px',
|
||||
border: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
backgroundColor: '#ffffff',
|
||||
overflow: 'hidden',
|
||||
boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)',
|
||||
boxSizing: 'border-box'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: '10px 16px',
|
||||
borderBottom: '1px solid rgba(5, 54, 89, 0.06)',
|
||||
background: 'linear-gradient(90deg, rgba(192, 18, 39, 0.03) 0%, rgba(255, 255, 255, 0) 100%)',
|
||||
gap: '10px',
|
||||
flexWrap: 'wrap'
|
||||
<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: { xs: 2, sm: 3 }, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette[color].lighter}66 0%, transparent 100%)`
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '30px',
|
||||
height: '30px',
|
||||
borderRadius: '7px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: 'rgba(192, 18, 39, 0.08)',
|
||||
color: '#C01227',
|
||||
flexShrink: 0
|
||||
}}
|
||||
>
|
||||
<Icon size={15} />
|
||||
</div>
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Heading level={5}>{title}</Heading>
|
||||
</div>
|
||||
<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}
|
||||
</div>
|
||||
<div style={{ padding: noPadding ? '0' : '16px', flexGrow: 1, boxSizing: 'border-box' }}>
|
||||
{children}
|
||||
</div>
|
||||
</Stack>
|
||||
<Box sx={{ p: noPadding ? 0 : { xs: 2, sm: 3 }, flexGrow: 1 }}>{children}</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [selectedDate, setSelectedDate] = useState(() => dayjs().format('YYYY-MM-DD'));
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('sm'));
|
||||
const [clients, setClients] = useState([]);
|
||||
const [team, setTeam] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Guards against out-of-order responses (e.g. React StrictMode's double-invoked
|
||||
// effect) so a stale request can never clobber a newer one's result.
|
||||
const loadRequestId = useRef(0);
|
||||
|
||||
const load = () => {
|
||||
const requestId = ++loadRequestId.current;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
Promise.all([fetchClients(), fetchUsers()])
|
||||
.then(([cs, us]) => {
|
||||
if (loadRequestId.current !== requestId) return;
|
||||
setClients((cs || []).map(toClient));
|
||||
setTeam((us || []).map(toUser));
|
||||
})
|
||||
.catch((e) => {
|
||||
if (loadRequestId.current === requestId) setError(e.message || 'Failed to load dashboard data');
|
||||
})
|
||||
.finally(() => {
|
||||
if (loadRequestId.current === requestId) setLoading(false);
|
||||
});
|
||||
.catch((e) => setError(e.message || 'Failed to load dashboard data'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
@@ -145,273 +103,148 @@ export default function Dashboard() {
|
||||
[clients]
|
||||
);
|
||||
|
||||
const recentColumns = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
header: <div style={{ paddingLeft: '24px' }}>Client</div>,
|
||||
width: proportional(2),
|
||||
renderCell: (c) => (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', paddingLeft: '24px' }}>
|
||||
<UserAvatar name={c.name} size={32} />
|
||||
<Text type="body" weight="semibold">{c.name}</Text>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'businessType',
|
||||
header: 'Type',
|
||||
width: proportional(1),
|
||||
renderCell: (c) => c.businessType
|
||||
? <Badge variant="neutral" label={titleCase(c.businessType)} />
|
||||
: <Text type="supporting" color="disabled">—</Text>
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
header: 'Location',
|
||||
width: proportional(1),
|
||||
renderCell: (c) => (
|
||||
<Text type="body">{`${c.city || '—'}${c.businessState ? `, ${c.businessState}` : ''}`}</Text>
|
||||
)
|
||||
},
|
||||
{
|
||||
key: 'parcelVolume',
|
||||
header: 'Parcels',
|
||||
width: pixel(110),
|
||||
align: 'end',
|
||||
renderCell: (c) => <Text type="body" weight="semibold" hasTabularNumbers>{c.parcelVolume.toLocaleString('en-IN')}</Text>
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
width: pixel(140),
|
||||
renderCell: (c) => <StatusChip status={c.status} />
|
||||
}
|
||||
], []);
|
||||
|
||||
const datePickerAction = (
|
||||
<input
|
||||
type="date"
|
||||
className="dashboard-date-picker"
|
||||
value={selectedDate}
|
||||
onChange={(e) => setSelectedDate(e.target.value)}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
borderRadius: '10px',
|
||||
border: '1px solid rgba(5, 54, 89, 0.12)',
|
||||
fontSize: '0.875rem',
|
||||
fontWeight: 500,
|
||||
fontFamily: 'inherit',
|
||||
color: '#1e293b',
|
||||
backgroundColor: '#ffffff',
|
||||
outline: 'none',
|
||||
cursor: 'pointer',
|
||||
transition: 'all 0.2s ease',
|
||||
boxShadow: '0 1px 2px rgba(15, 23, 42, 0.04)',
|
||||
width: '150px'
|
||||
}}
|
||||
onFocus={(e) => {
|
||||
e.target.style.borderColor = '#C01227';
|
||||
e.target.style.boxShadow = '0 0 0 3px rgba(192, 18, 39, 0.12)';
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
e.target.style.borderColor = 'rgba(5, 54, 89, 0.12)';
|
||||
e.target.style.boxShadow = '0 1px 2px rgba(15, 23, 42, 0.04)';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const today = new Date().toLocaleDateString('en-IN', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' });
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader title="Dashboard" action={datePickerAction} />
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '96px 0' }}>
|
||||
<div className="spinner" style={{ width: '40px', height: '40px', border: '4px solid #f1f5f9', borderTop: '4px solid #C01227', borderRadius: '50%', animation: 'spin 1s linear infinite' }} />
|
||||
<style>{`@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }`}</style>
|
||||
</div>
|
||||
</div>
|
||||
<>
|
||||
<PageHeader title="Dashboard" breadcrumbs={[{ label: 'Dashboard' }]} />
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 12 }}><CircularProgress /></Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
||||
<PageHeader title="Dashboard" action={datePickerAction} />
|
||||
<>
|
||||
<PageHeader
|
||||
title="Dashboard"
|
||||
breadcrumbs={[{ label: 'Dashboard' }]}
|
||||
action={<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load}>Refresh</Button>}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div style={{ marginBottom: '24px' }}>
|
||||
<Banner
|
||||
status="error"
|
||||
title={error}
|
||||
endContent={<Button label="Retry" variant="ghost" size="sm" onClick={load} />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && <Alert severity="error" sx={{ mb: 2.5 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '12px', marginBottom: '16px' }}>
|
||||
<StatCard size="sm" title="Total Clients" value={stats.total} icon={Building2} color="primary" />
|
||||
<StatCard size="sm" title="New Clients" value={stats.newCount} icon={UserPlus} color="info" />
|
||||
<StatCard size="sm" title="Total Parcel Volume" value={stats.parcels.toLocaleString('en-IN')} icon={Package} color="success" />
|
||||
<StatCard size="sm" title="Active Contracts" value={stats.contracts} icon={Handshake} color="warning" />
|
||||
</div>
|
||||
|
||||
{/* Main dashboard panels */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '16px', marginBottom: '16px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
|
||||
{/* Recent Clients */}
|
||||
<div style={{ gridColumn: 'span 8' }} className="col-span-8">
|
||||
<Panel
|
||||
icon={History}
|
||||
title="Recent Clients"
|
||||
color="primary"
|
||||
noPadding
|
||||
action={recent.length > 0 && (
|
||||
<Button
|
||||
label="View all"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
icon={<ArrowRight size={14} />}
|
||||
onClick={() => navigate('/clients')}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{recent.length === 0 ? (
|
||||
<EmptyState icon={Building2} title="No clients yet" caption="Add a client under the Clients menu to see it here." />
|
||||
) : (
|
||||
<div style={{ paddingTop: '8px', paddingBottom: '8px' }}>
|
||||
<Table
|
||||
data={recent}
|
||||
columns={recentColumns}
|
||||
idKey="id"
|
||||
density="compact"
|
||||
dividers="rows"
|
||||
hasHover
|
||||
isStriped
|
||||
textOverflow="truncate"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
{/* Clients by Status */}
|
||||
<div style={{ gridColumn: 'span 4' }} className="col-span-4">
|
||||
<Panel icon={PieChart} title="Clients by Status" color="primary">
|
||||
<div style={{ display: 'flex', justifyContent: 'center', padding: '4px 0' }}>
|
||||
{statusData.length === 0 ? (
|
||||
<EmptyState icon={PieChart} title="No status breakdown" caption="No client status distribution available." />
|
||||
) : (
|
||||
<DonutChart data={statusData} size={140} thickness={20} centerValue={stats.total} centerLabel="Clients" />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr', lg: 'repeat(12, 1fr)', gap: '16px' }} className="responsive-row">
|
||||
{/* Business Type bar chart */}
|
||||
<div style={{ gridColumn: 'span 8' }} className="col-span-8">
|
||||
<Panel icon={Layers} title="Clients by Business Type" color="primary">
|
||||
{byType.length === 0 ? (
|
||||
<EmptyState icon={Layers} title="No business types" caption="No business type distribution available." />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||
{byType.map(([type, count], i) => (
|
||||
<div key={type}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '4px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{ width: '10px', height: '10px', borderRadius: '3px', backgroundColor: BAR_COLORS[i % BAR_COLORS.length] }} />
|
||||
<Text type="body" weight="semibold">{titleCase(type)}</Text>
|
||||
</div>
|
||||
<Text type="body" color="secondary" hasTabularNumbers>
|
||||
{count} · {Math.round((count / clients.length) * 100)}%
|
||||
</Text>
|
||||
</div>
|
||||
<div style={{ height: '6px', borderRadius: '3px', backgroundColor: '#f1f5f9', width: '100%', overflow: 'hidden' }}>
|
||||
<div style={{ height: '100%', borderRadius: '3px', backgroundColor: BAR_COLORS[i % BAR_COLORS.length], width: `${(count / maxType) * 100}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<Grid item xs={12} lg={8}>
|
||||
<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." />
|
||||
) : isMobile ? (
|
||||
<Stack divider={<Divider />}>
|
||||
{recent.map((c) => (
|
||||
<Stack key={c.id} direction="row" spacing={1.25} alignItems="center" sx={{ px: 2, py: 1.5 }}>
|
||||
<UserAvatar name={c.name} size={36} />
|
||||
<Box sx={{ minWidth: 0, flexGrow: 1 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }} noWrap>{c.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap sx={{ display: 'block' }}>
|
||||
{[titleCase(c.businessType), c.city].filter(Boolean).join(' · ') || '—'} · {c.parcelVolume.toLocaleString('en-IN')} parcels
|
||||
</Typography>
|
||||
</Box>
|
||||
<StatusChip status={c.status} />
|
||||
</Stack>
|
||||
))}
|
||||
</Stack>
|
||||
) : (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
{/* App Users */}
|
||||
<div style={{ gridColumn: 'span 4' }} className="col-span-4">
|
||||
<Panel
|
||||
icon={Users}
|
||||
title="App Users"
|
||||
color="primary"
|
||||
>
|
||||
{team.length === 0 ? (
|
||||
<EmptyState title="No team users" />
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{team.slice(0, 6).map((u) => (
|
||||
<div
|
||||
key={u.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '10px',
|
||||
padding: '8px 12px',
|
||||
margin: '1px -12px',
|
||||
borderRadius: '10px',
|
||||
transition: 'background-color 0.2s ease',
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
className="team-row-hover"
|
||||
>
|
||||
<UserAvatar name={u.name} size={30} />
|
||||
<div style={{ flexGrow: 1, minWidth: 0 }}>
|
||||
<Text type="body" weight="semibold" maxLines={1}>{u.name}</Text>
|
||||
<Text type="supporting" color="secondary" maxLines={1}>{u.email}</Text>
|
||||
</div>
|
||||
<StatusChip status={u.role} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<style>{`
|
||||
@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;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stat card hover transition effects */
|
||||
.stat-card {
|
||||
transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1), border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||
}
|
||||
.stat-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.08) !important;
|
||||
border-color: rgba(192, 18, 39, 0.2) !important;
|
||||
}
|
||||
<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>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
</Panel>
|
||||
</Grid>
|
||||
|
||||
/* Team member row hover polish */
|
||||
.team-row-hover:hover {
|
||||
background-color: rgba(192, 18, 39, 0.04) !important;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user