Add console pages for previously plumbing-only Doormile API resources

Hubs, Vehicles, App Users, Tripsheets, Exceptions, and Competitive Intel
had full CRUD support in doormileApi.js but no UI anywhere in the console.
B2C Customers had neither. Field bindings for Pricing/Vehicles/Tripsheets/
Exceptions/Customers are confirmed against live read-only API responses;
App Users' write body and both Competitive Intel resources (no documented
schema at all for competitor-branches/carrier-pricing) are still best-effort
guesses and unverified against a real write.

Routed under a new "Fleet & Ops" sidebar group except Customers, which sits
at the top level next to Pricing.
This commit is contained in:
2026-08-06 19:26:42 +05:30
parent 59f31c8adf
commit d144ff71a9
7 changed files with 2519 additions and 0 deletions

View File

@@ -0,0 +1,358 @@
import React, { useMemo, useState } from 'react';
import {
Avatar,
Box,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Button,
Grid,
IconButton,
InputLabel,
Paper,
Stack,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
Tooltip,
Typography,
useMediaQuery,
useTheme
} from '@mui/material';
import { MdPeopleAlt, MdOutlinePeopleAlt, MdEdit, MdPhone, MdMail, MdOutlineMailOutline, MdOutlinePhoneAndroid } from 'react-icons/md';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import Loader from 'components/Loader';
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
import PageHeader from 'components/nearle_components/PageHeader';
import StatCard from 'components/nearle_components/StatCard';
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
import { getAdminCustomers, updateAdminCustomer } from 'pages/api/doormileApi';
import { enqueueSnackbar } from 'notistack';
// ============================================================================
// GET /admin/customers confirmed live: { appcustomerid, createdat, email,
// name, phone, totalbookings } — no city/address/firstname field exists at
// all. PATCH /admin/customers/:id is the only mutation the API exposes;
// there is no create/delete here by design ("Tenant-scoped through their
// bookings" — a customer only exists once they've ordered through a tenant).
// ============================================================================
const DT = {
radiusCard: 14,
radiusField: 10,
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
textPrimary: '#0f172a',
textSecondary: '#64748b',
textMuted: '#94a3b8',
borderSubtle: '#e2e8f0',
borderHover: '#cbd5e1',
divider: '#f1f5f9',
surface: '#ffffff',
surfaceAlt: '#f8fafc'
};
const a = (c, suffix) => `${c}${suffix}`;
const soft = (c) => a(c, '18');
const ring = (c) => a(c, '26');
const edge = (c) => a(c, '55');
const BRAND = '#C01227';
const AccentAvatar = ({ color, size = 24, children }) => (
<Avatar sx={{ width: size, height: size, bgcolor: soft(color), color }}>{children}</Avatar>
);
const custId = (row) => row.appcustomerid;
const custName = (row) => row.name || '—';
const custPhone = (row) => row.phone || '—';
const custEmail = (row) => row.email || '—';
const custBookings = (row) => row.totalbookings ?? 0;
const Customers = () => {
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const queryClient = useQueryClient();
const [searchword, setSearchword] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [editRow, setEditRow] = useState(null);
const [form, setForm] = useState({});
const { data: customers = [], isLoading } = useQuery({ queryKey: ['admin-customers'], queryFn: getAdminCustomers });
const rows = useMemo(() => {
if (!debouncedSearch) return customers;
const q = debouncedSearch.toLowerCase().trim();
return customers.filter((row) =>
[custName(row), custPhone(row), custEmail(row)].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
);
}, [customers, debouncedSearch]);
const stats = useMemo(() => {
const total = customers.length;
const withEmail = customers.filter((c) => c.email).length;
return { total, withEmail };
}, [customers]);
const opentoast = (message, variant = 'error') =>
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
const saveMutation = useMutation({
mutationFn: () => updateAdminCustomer(custId(editRow), form),
onSuccess: (res) => {
if (res.success) {
opentoast('Customer updated', 'success');
queryClient.invalidateQueries({ queryKey: ['admin-customers'] });
setEditRow(null);
} else {
opentoast(res.message || 'Failed to update customer');
}
},
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update customer')
});
const openEdit = (row) => {
setEditRow(row);
setForm({
name: row.name || '',
phone: row.phone || '',
email: row.email || ''
});
};
return (
<>
{isLoading && <Loader />}
<PageHeader title="Customers" subtitle="Live · B2C Directory" live />
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
<Grid item xs={6} md={3}>
<StatCard title="Total Customers" value={stats.total} icon={<MdOutlinePeopleAlt size={20} />} color={BRAND} />
</Grid>
<Grid item xs={6} md={3}>
<StatCard title="With Email" value={stats.withEmail} icon={<MdOutlineMailOutline size={20} />} color="#0ea5e9" />
</Grid>
</Grid>
<Paper
elevation={0}
sx={{
mt: { xs: 1.5, md: 2 },
p: { xs: 1, md: 1.5 },
borderTopLeftRadius: DT.radiusCard / 8,
borderTopRightRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
borderBottom: 0,
background: '#fff'
}}
>
<Stack direction={{ xs: 'column', sm: 'row' }} alignItems={{ xs: 'stretch', sm: 'center' }} justifyContent="space-between" spacing={1.25}>
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
{customers.length} total · {rows.length} shown
</Typography>
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
<DebounceSearchBar
value={searchword}
onChange={setSearchword}
onDebouncedChange={setDebouncedSearch}
placeholder="Search customers (ctrl+k)"
sx={{
m: 0,
width: '100%',
borderRadius: DT.radiusField + 'px',
bgcolor: DT.surface,
'& fieldset': { borderColor: DT.borderSubtle, borderWidth: 1 },
'&:hover fieldset': { borderColor: DT.borderHover },
'&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 1.5 },
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
}}
/>
</Box>
</Stack>
</Paper>
<Paper
elevation={0}
sx={{
borderBottomLeftRadius: DT.radiusCard / 8,
borderBottomRightRadius: DT.radiusCard / 8,
border: '1px solid',
borderColor: DT.borderSubtle,
overflow: 'hidden',
background: '#fff'
}}
>
{isMobile ? (
rows.length === 0 && !isLoading ? (
<Stack alignItems="center" spacing={1.5} sx={{ py: 6, px: 2 }}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdPeopleAlt size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No customers to show
</Typography>
</Stack>
) : (
<MobileCardList scroll>
{rows.map((row, index) => (
<MobileCard
key={custId(row) || index}
accent={BRAND}
header={
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color={BRAND} size={36}>
<MdPeopleAlt size={18} />
</AccentAvatar>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }} noWrap>
{custName(row)}
</Typography>
</Stack>
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
<MdEdit size={14} />
</IconButton>
</Stack>
}
>
<MobileFieldGrid>
<MobileField label="Phone" value={custPhone(row)} />
<MobileField label="Email" value={custEmail(row)} />
<MobileField label="Bookings" value={custBookings(row)} />
</MobileFieldGrid>
</MobileCard>
))}
</MobileCardList>
)
) : (
<TableContainer
sx={{
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
'&::-webkit-scrollbar': { width: 10, height: 10 },
'&::-webkit-scrollbar-thumb': { backgroundColor: edge(BRAND), borderRadius: 8 },
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
}}
>
<Table stickyHeader sx={{ minWidth: 720 }}>
<TableHead>
<TableRow
sx={{
'& th': {
backgroundColor: DT.surfaceAlt,
color: DT.textSecondary,
fontSize: 11,
fontWeight: 800,
letterSpacing: 0.6,
textTransform: 'uppercase',
whiteSpace: 'nowrap',
borderBottom: `1px solid ${DT.borderSubtle}`
}
}}
>
<TableCell>#</TableCell>
<TableCell>Name</TableCell>
<TableCell>Phone</TableCell>
<TableCell>Email</TableCell>
<TableCell align="center">Bookings</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{isLoading && <OrdersTableSkeleton col={4} />}
{rows.length === 0 && !isLoading ? (
<TableRow>
<TableCell colSpan={6} sx={{ py: 6 }}>
<Stack alignItems="center" spacing={1.5}>
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
<MdPeopleAlt size={28} />
</Avatar>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No customers to show
</Typography>
</Stack>
</TableCell>
</TableRow>
) : (
rows.map((row, index) => (
<TableRow
key={custId(row) || index}
sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}
>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={1}>
<AccentAvatar color={BRAND} size={32}>
<MdPeopleAlt size={16} />
</AccentAvatar>
<Typography variant="body2" sx={{ fontWeight: 700 }}>
{custName(row)}
</Typography>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={0.5}>
<MdPhone size={13} color={DT.textMuted} /> <Typography variant="body2">{custPhone(row)}</Typography>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" alignItems="center" spacing={0.5}>
<MdMail size={13} color={DT.textMuted} /> <Typography variant="body2">{custEmail(row)}</Typography>
</Stack>
</TableCell>
<TableCell align="center">{custBookings(row)}</TableCell>
<TableCell align="right">
<Tooltip title="Edit customer">
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
<MdEdit size={16} />
</IconButton>
</Tooltip>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
<Dialog open={!!editRow} onClose={() => setEditRow(null)} maxWidth="xs" fullWidth>
<DialogTitle sx={{ fontWeight: 700 }}>Edit Customer</DialogTitle>
<DialogContent>
<Stack spacing={2} sx={{ mt: 1 }}>
<Stack spacing={1}>
<InputLabel>Name</InputLabel>
<TextField value={form.name || ''} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} />
</Stack>
<Stack spacing={1}>
<InputLabel>Phone</InputLabel>
<TextField
value={form.phone || ''}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
InputProps={{ startAdornment: <MdOutlinePhoneAndroid size={14} style={{ marginRight: 6, color: DT.textMuted }} /> }}
/>
</Stack>
<Stack spacing={1}>
<InputLabel>Email</InputLabel>
<TextField value={form.email || ''} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
</Stack>
</Stack>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button onClick={() => setEditRow(null)}>Cancel</Button>
<Button variant="contained" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending} sx={{ bgcolor: BRAND }}>
Save Changes
</Button>
</DialogActions>
</Dialog>
</>
);
};
export default Customers;