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:
335
src/pages/nearle/appUsers/appUsers.js
Normal file
335
src/pages/nearle/appUsers/appUsers.js
Normal file
@@ -0,0 +1,335 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { MdOutlineAdminPanelSettings, MdEdit, MdDeleteOutline, MdAdd } 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 { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import { getAppUsers, createAppUser, updateAppUser, deleteAppUser } from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
// ============================================================================
|
||||
// GET /admin/users confirmed live: { id, email, first_name, phone, role,
|
||||
// status } — role is a free-text string ("admin", "rep" both observed
|
||||
// live; the auth doc's "1 admin, 3 manager, 4 executive" describes the JWT
|
||||
// roleid encoding for login, not this list's field). No `name`/`roleid` at
|
||||
// all. POST/PUT body shape for create/edit is still unconfirmed (write
|
||||
// endpoints, not exercised live this pass) — kept as first_name/phone/role
|
||||
// to match the GET shape.
|
||||
// ============================================================================
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
radiusField: 10,
|
||||
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 BRAND = '#C01227';
|
||||
const ROLES = ['admin', 'manager', 'executive', 'rep'];
|
||||
const roleLabel = (role) => (role ? role.charAt(0).toUpperCase() + role.slice(1) : '—');
|
||||
|
||||
const emptyForm = { id: null, first_name: '', email: '', phone: '', password: '', role: 'admin', status: 'Active' };
|
||||
|
||||
const AppUsers = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({ queryKey: ['admin-app-users'], queryFn: getAppUsers });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return users;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return users.filter((row) => [row.first_name, row.email, row.phone].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
|
||||
}, [users, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = users.length;
|
||||
const admins = users.filter((u) => String(u.role).toLowerCase() === 'admin').length;
|
||||
return { total, admins };
|
||||
}, [users]);
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload) => (payload.id ? updateAppUser(payload.id, payload.data) : createAppUser(payload.data)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast(form.id ? 'User updated' : 'User created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-app-users'] });
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to save user');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save user')
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => deleteAppUser(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('User deleted', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-app-users'] });
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to delete user');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete user')
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
id: row.id,
|
||||
first_name: row.first_name || '',
|
||||
email: row.email || '',
|
||||
phone: row.phone || '',
|
||||
password: '',
|
||||
role: row.role || 'admin',
|
||||
status: row.status || 'Active'
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
if (window.confirm(`Delete staff user "${row.first_name}"?`)) deleteMutation.mutate(row.id);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.first_name || !form.email || (!form.id && !form.password)) {
|
||||
opentoast('Fill Name, Email' + (!form.id ? ' and Password' : ''));
|
||||
return;
|
||||
}
|
||||
const { id, ...data } = form;
|
||||
if (id && !data.password) delete data.password;
|
||||
saveMutation.mutate({ id, data });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="App Users"
|
||||
subtitle="Live · Staff Logins"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
New User
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={6}>
|
||||
<StatCard title="Total Staff" value={stats.total} icon={<MdOutlineAdminPanelSettings size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<StatCard title="Admins" value={stats.admins} icon={<MdOutlineAdminPanelSettings size={20} />} color="#6366f1" />
|
||||
</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 }}>
|
||||
{users.length} total · {rows.length} shown
|
||||
</Typography>
|
||||
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
|
||||
<DebounceSearchBar
|
||||
value={searchword}
|
||||
onChange={setSearchword}
|
||||
onDebouncedChange={setDebouncedSearch}
|
||||
placeholder="Search staff (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' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 820 : 880 }}>
|
||||
<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>Email</TableCell>
|
||||
<TableCell>Phone</TableCell>
|
||||
<TableCell>Role</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={4} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdOutlineAdminPanelSettings size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No staff users to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.id || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{row.first_name}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{row.email}</TableCell>
|
||||
<TableCell>{row.phone || '—'}</TableCell>
|
||||
<TableCell>{roleLabel(row.role)}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Edit user">
|
||||
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
|
||||
<MdEdit size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete user">
|
||||
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
|
||||
<MdDeleteOutline size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{form.id ? 'Edit User' : 'New Staff User'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Name</InputLabel>
|
||||
<TextField value={form.first_name} onChange={(e) => setForm((f) => ({ ...f, first_name: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Email</InputLabel>
|
||||
<TextField type="email" value={form.email} onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Phone</InputLabel>
|
||||
<TextField value={form.phone} onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>{form.id ? 'New Password (leave blank to keep unchanged)' : 'Password'}</InputLabel>
|
||||
<TextField type="password" value={form.password} onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Role</InputLabel>
|
||||
<Select fullWidth value={form.role} onChange={(e) => setForm((f) => ({ ...f, role: e.target.value }))}>
|
||||
{ROLES.map((r) => (
|
||||
<MenuItem key={r} value={r}>
|
||||
{roleLabel(r)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<MenuItem value="Active">Active</MenuItem>
|
||||
<MenuItem value="Inactive">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
|
||||
{form.id ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppUsers;
|
||||
386
src/pages/nearle/competitiveIntel/competitiveIntel.js
Normal file
386
src/pages/nearle/competitiveIntel/competitiveIntel.js
Normal file
@@ -0,0 +1,386 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography
|
||||
} from '@mui/material';
|
||||
import { MdRadar, MdAdd, MdEdit, MdDeleteOutline } from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import {
|
||||
getCompetitorBranches,
|
||||
createCompetitorBranch,
|
||||
updateCompetitorBranch,
|
||||
deleteCompetitorBranch,
|
||||
getCarrierPricing,
|
||||
createCarrierPricing,
|
||||
updateCarrierPricing,
|
||||
deleteCarrierPricing
|
||||
} from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
// ============================================================================
|
||||
// Neither /admin/competitor-branches nor /admin/carrier-pricing document a
|
||||
// request/response body anywhere — express-console-api.md lists only the
|
||||
// routes. The fields below (branchname/city/address for branches,
|
||||
// carriername/vehicletype/baseprice/priceperkm for pricing) are a best-effort
|
||||
// guess following the naming conventions used elsewhere in this API and are
|
||||
// UNVERIFIED — expect the create/edit calls here to need correction once a
|
||||
// real request/response has been seen.
|
||||
// ============================================================================
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
borderSubtle: '#e2e8f0',
|
||||
divider: '#f1f5f9',
|
||||
surfaceAlt: '#f8fafc'
|
||||
};
|
||||
const a = (c, suffix) => `${c}${suffix}`;
|
||||
const soft = (c) => a(c, '18');
|
||||
const BRAND = '#C01227';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'branches', label: 'Competitor Branches' },
|
||||
{ key: 'pricing', label: 'Carrier Pricing' }
|
||||
];
|
||||
|
||||
const emptyBranch = { id: null, branchname: '', competitorname: '', city: '', address: '' };
|
||||
const emptyCarrier = { id: null, carriername: '', vehicletype: '', baseprice: '', priceperkm: '' };
|
||||
|
||||
const CompetitiveIntel = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const [tab, setTab] = useState('branches');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [branchForm, setBranchForm] = useState(emptyBranch);
|
||||
const [carrierForm, setCarrierForm] = useState(emptyCarrier);
|
||||
|
||||
const { data: branches = [], isLoading: branchesLoading } = useQuery({
|
||||
queryKey: ['competitor-branches'],
|
||||
queryFn: getCompetitorBranches
|
||||
});
|
||||
const { data: carrierPricing = [], isLoading: carrierLoading } = useQuery({
|
||||
queryKey: ['carrier-pricing'],
|
||||
queryFn: getCarrierPricing
|
||||
});
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const branchMutation = useMutation({
|
||||
mutationFn: (payload) => (payload.id ? updateCompetitorBranch(payload.id, payload.data) : createCompetitorBranch(payload.data)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast(branchForm.id ? 'Branch updated' : 'Branch created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['competitor-branches'] });
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to save branch');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save branch')
|
||||
});
|
||||
|
||||
const branchDeleteMutation = useMutation({
|
||||
mutationFn: (id) => deleteCompetitorBranch(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Branch deleted', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['competitor-branches'] });
|
||||
} else opentoast(res.message || 'Failed to delete branch');
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete branch')
|
||||
});
|
||||
|
||||
const carrierMutation = useMutation({
|
||||
mutationFn: (payload) => (payload.id ? updateCarrierPricing(payload.id, payload.data) : createCarrierPricing(payload.data)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast(carrierForm.id ? 'Carrier pricing updated' : 'Carrier pricing created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['carrier-pricing'] });
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to save carrier pricing');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save carrier pricing')
|
||||
});
|
||||
|
||||
const carrierDeleteMutation = useMutation({
|
||||
mutationFn: (id) => deleteCarrierPricing(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Carrier pricing deleted', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['carrier-pricing'] });
|
||||
} else opentoast(res.message || 'Failed to delete carrier pricing');
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete carrier pricing')
|
||||
});
|
||||
|
||||
const idOf = (row) => row.id ?? row.competitorbranchid ?? row.carrierpricingid;
|
||||
|
||||
const openCreate = () => {
|
||||
if (tab === 'branches') setBranchForm(emptyBranch);
|
||||
else setCarrierForm(emptyCarrier);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
if (tab === 'branches') {
|
||||
setBranchForm({
|
||||
id: idOf(row),
|
||||
branchname: row.branchname || '',
|
||||
competitorname: row.competitorname || '',
|
||||
city: row.city || '',
|
||||
address: row.address || ''
|
||||
});
|
||||
} else {
|
||||
setCarrierForm({
|
||||
id: idOf(row),
|
||||
carriername: row.carriername || '',
|
||||
vehicletype: row.vehicletype || '',
|
||||
baseprice: row.baseprice ?? '',
|
||||
priceperkm: row.priceperkm ?? ''
|
||||
});
|
||||
}
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
if (!window.confirm('Delete this record?')) return;
|
||||
if (tab === 'branches') branchDeleteMutation.mutate(idOf(row));
|
||||
else carrierDeleteMutation.mutate(idOf(row));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (tab === 'branches') {
|
||||
if (!branchForm.branchname) {
|
||||
opentoast('Fill Branch Name');
|
||||
return;
|
||||
}
|
||||
const { id, ...data } = branchForm;
|
||||
branchMutation.mutate({ id, data });
|
||||
} else {
|
||||
if (!carrierForm.carriername || !carrierForm.baseprice) {
|
||||
opentoast('Fill Carrier Name and Base Price');
|
||||
return;
|
||||
}
|
||||
const { id, ...data } = carrierForm;
|
||||
carrierMutation.mutate({ id, data: { ...data, baseprice: Number(data.baseprice) || 0, priceperkm: Number(data.priceperkm) || 0 } });
|
||||
}
|
||||
};
|
||||
|
||||
const rows = tab === 'branches' ? branches : carrierPricing;
|
||||
const isLoading = tab === 'branches' ? branchesLoading : carrierLoading;
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || branchMutation.isPending || carrierMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Competitive Intel"
|
||||
subtitle="Live · Market Benchmarking"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
{tab === 'branches' ? 'New Branch' : 'New Carrier Rate'}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack direction="row" spacing={1} sx={{ mb: 2 }}>
|
||||
{TABS.map((t) => (
|
||||
<Box
|
||||
key={t.key}
|
||||
onClick={() => setTab(t.key)}
|
||||
sx={{
|
||||
px: 2,
|
||||
py: 0.75,
|
||||
borderRadius: 999,
|
||||
cursor: 'pointer',
|
||||
fontWeight: 700,
|
||||
fontSize: 13,
|
||||
bgcolor: tab === t.key ? BRAND : soft(BRAND),
|
||||
color: tab === t.key ? '#fff' : BRAND,
|
||||
border: `1px solid ${tab === t.key ? BRAND : soft(BRAND)}`
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: DT.borderSubtle, borderRadius: DT.radiusCard / 8 + 'px', overflow: 'hidden', background: '#fff' }}>
|
||||
<TableContainer sx={{ maxHeight: 'calc(100vh - 260px)' }}>
|
||||
<Table stickyHeader sx={{ minWidth: 720 }}>
|
||||
<TableHead>
|
||||
{tab === 'branches' ? (
|
||||
<TableRow sx={{ '& th': { backgroundColor: DT.surfaceAlt, color: DT.textSecondary, fontSize: 11, fontWeight: 800, textTransform: 'uppercase', borderBottom: `1px solid ${DT.borderSubtle}` } }}>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Branch Name</TableCell>
|
||||
<TableCell>Competitor</TableCell>
|
||||
<TableCell>City</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<TableRow sx={{ '& th': { backgroundColor: DT.surfaceAlt, color: DT.textSecondary, fontSize: 11, fontWeight: 800, textTransform: 'uppercase', borderBottom: `1px solid ${DT.borderSubtle}` } }}>
|
||||
<TableCell>#</TableCell>
|
||||
<TableCell>Carrier</TableCell>
|
||||
<TableCell>Vehicle Type</TableCell>
|
||||
<TableCell align="center">Base Price</TableCell>
|
||||
<TableCell align="center">Price/Km</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{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 }}>
|
||||
<MdRadar size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No records to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={idOf(row) || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
{tab === 'branches' ? (
|
||||
<>
|
||||
<TableCell>{row.branchname || '—'}</TableCell>
|
||||
<TableCell>{row.competitorname || '—'}</TableCell>
|
||||
<TableCell>{row.city || '—'}</TableCell>
|
||||
<TableCell>{row.address || '—'}</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>{row.carriername || '—'}</TableCell>
|
||||
<TableCell>{row.vehicletype || '—'}</TableCell>
|
||||
<TableCell align="center">{row.baseprice ?? '—'}</TableCell>
|
||||
<TableCell align="center">{row.priceperkm ?? '—'}</TableCell>
|
||||
</>
|
||||
)}
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
|
||||
<MdEdit size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
|
||||
<MdDeleteOutline size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>
|
||||
{tab === 'branches' ? (branchForm.id ? 'Edit Branch' : 'New Competitor Branch') : carrierForm.id ? 'Edit Carrier Rate' : 'New Carrier Rate'}
|
||||
</DialogTitle>
|
||||
<DialogContent>
|
||||
{tab === 'branches' ? (
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Branch Name</InputLabel>
|
||||
<TextField value={branchForm.branchname} onChange={(e) => setBranchForm((f) => ({ ...f, branchname: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Competitor Name</InputLabel>
|
||||
<TextField value={branchForm.competitorname} onChange={(e) => setBranchForm((f) => ({ ...f, competitorname: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>City</InputLabel>
|
||||
<TextField value={branchForm.city} onChange={(e) => setBranchForm((f) => ({ ...f, city: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Address</InputLabel>
|
||||
<TextField value={branchForm.address} onChange={(e) => setBranchForm((f) => ({ ...f, address: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Carrier Name</InputLabel>
|
||||
<TextField value={carrierForm.carriername} onChange={(e) => setCarrierForm((f) => ({ ...f, carriername: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle Type</InputLabel>
|
||||
<TextField value={carrierForm.vehicletype} onChange={(e) => setCarrierForm((f) => ({ ...f, vehicletype: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Base Price</InputLabel>
|
||||
<TextField type="number" value={carrierForm.baseprice} onChange={(e) => setCarrierForm((f) => ({ ...f, baseprice: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Price/Km</InputLabel>
|
||||
<TextField type="number" value={carrierForm.priceperkm} onChange={(e) => setCarrierForm((f) => ({ ...f, priceperkm: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompetitiveIntel;
|
||||
358
src/pages/nearle/customers/customers.js
Normal file
358
src/pages/nearle/customers/customers.js
Normal 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;
|
||||
371
src/pages/nearle/exceptions/exceptions.js
Normal file
371
src/pages/nearle/exceptions/exceptions.js
Normal file
@@ -0,0 +1,371 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { MdWarningAmber, MdAdd, MdCheckCircleOutline } 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 { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import { getExceptions, createException, updateExceptionStatus, getHubs } from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
radiusField: 10,
|
||||
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 BRAND = '#C01227';
|
||||
|
||||
const EXCEPTION_TYPES = ['Lost', 'Damaged', 'Misrouted', 'Receiver_Refused', 'Missing_Contents', 'Undeliverable'];
|
||||
const SEVERITIES = ['Low', 'Medium', 'High', 'Critical'];
|
||||
const SEVERITY_COLOR = { Low: '#94a3b8', Medium: '#f59e0b', High: '#ef4444', Critical: '#7f1d1d' };
|
||||
|
||||
const emptyForm = { consignmentid: '', tripsheetid: '', hub: null, exceptiontype: 'Damaged', severity: 'Medium', description: '' };
|
||||
|
||||
const Exceptions = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [resolveRow, setResolveRow] = useState(null);
|
||||
const [resolution, setResolution] = useState('');
|
||||
const [resolveStatus, setResolveStatus] = useState('Resolved');
|
||||
|
||||
const { data: exceptions = [], isLoading } = useQuery({ queryKey: ['admin-exceptions'], queryFn: getExceptions });
|
||||
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return exceptions;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return exceptions.filter((row) =>
|
||||
[row.exceptiontype, row.severity, row.description, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q))
|
||||
);
|
||||
}, [exceptions, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = exceptions.length;
|
||||
const open = exceptions.filter((e) => !['Resolved', 'Closed'].includes(e.status)).length;
|
||||
const critical = exceptions.filter((e) => e.severity === 'Critical').length;
|
||||
return { total, open, critical };
|
||||
}, [exceptions]);
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => createException(data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Exception logged', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-exceptions'] });
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to log exception');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to log exception')
|
||||
});
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: () => updateExceptionStatus(resolveRow.exceptionid, resolveStatus, resolution),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Exception updated', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-exceptions'] });
|
||||
setResolveRow(null);
|
||||
setResolution('');
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to update exception');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update exception')
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!form.consignmentid || !form.description) {
|
||||
opentoast('Fill Consignment ID and Description');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
consignmentid: form.consignmentid,
|
||||
tripsheetid: form.tripsheetid || undefined,
|
||||
hubid: form.hub?.hubid,
|
||||
exceptiontype: form.exceptiontype,
|
||||
severity: form.severity,
|
||||
description: form.description
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || createMutation.isPending || resolveMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Exceptions"
|
||||
subtitle="Live · Lost / Damaged / Misrouted Parcels"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
Log Exception
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Total" value={stats.total} icon={<MdWarningAmber size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Open" value={stats.open} icon={<MdWarningAmber size={20} />} color="#f59e0b" />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Critical" value={stats.critical} icon={<MdWarningAmber size={20} />} color="#ef4444" />
|
||||
</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 }}>
|
||||
{exceptions.length} total · {rows.length} shown
|
||||
</Typography>
|
||||
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
|
||||
<DebounceSearchBar
|
||||
value={searchword}
|
||||
onChange={setSearchword}
|
||||
onDebouncedChange={setDebouncedSearch}
|
||||
placeholder="Search exceptions (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' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 960 : 1020 }}>
|
||||
<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>Consignment</TableCell>
|
||||
<TableCell>Hub</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Description</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={6} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdWarningAmber size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No exceptions logged
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.exceptionid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{row.consignmentid}</TableCell>
|
||||
<TableCell>{hubMap.get(row.hubid)?.hubname || (row.hubid ? `Hub #${row.hubid}` : '—')}</TableCell>
|
||||
<TableCell>{row.exceptiontype}</TableCell>
|
||||
<TableCell>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'inline-flex',
|
||||
px: 1,
|
||||
py: 0.375,
|
||||
borderRadius: 999,
|
||||
bgcolor: soft(SEVERITY_COLOR[row.severity] || '#94a3b8'),
|
||||
color: SEVERITY_COLOR[row.severity] || '#94a3b8',
|
||||
fontSize: 11,
|
||||
fontWeight: 800
|
||||
}}
|
||||
>
|
||||
{row.severity}
|
||||
</Box>
|
||||
</TableCell>
|
||||
<TableCell sx={{ maxWidth: 220 }}>
|
||||
<Typography variant="body2" noWrap title={row.description}>
|
||||
{row.description}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{row.status || 'Open'}</TableCell>
|
||||
<TableCell align="right">
|
||||
{!['Resolved', 'Closed'].includes(row.status) && (
|
||||
<Tooltip title="Resolve / Close">
|
||||
<IconButton size="small" onClick={() => setResolveRow(row)} sx={{ color: '#10b981' }}>
|
||||
<MdCheckCircleOutline size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Log Exception</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Consignment ID</InputLabel>
|
||||
<TextField value={form.consignmentid} onChange={(e) => setForm((f) => ({ ...f, consignmentid: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Tripsheet ID (optional)</InputLabel>
|
||||
<TextField value={form.tripsheetid} onChange={(e) => setForm((f) => ({ ...f, tripsheetid: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Hub (optional)</InputLabel>
|
||||
<Autocomplete
|
||||
options={hubs || []}
|
||||
getOptionLabel={(option) => option.hubname || ''}
|
||||
value={form.hub}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, hub: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose hub" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select fullWidth value={form.exceptiontype} onChange={(e) => setForm((f) => ({ ...f, exceptiontype: e.target.value }))}>
|
||||
{EXCEPTION_TYPES.map((t) => (
|
||||
<MenuItem key={t} value={t}>
|
||||
{t.replace(/_/g, ' ')}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Severity</InputLabel>
|
||||
<Select fullWidth value={form.severity} onChange={(e) => setForm((f) => ({ ...f, severity: e.target.value }))}>
|
||||
{SEVERITIES.map((s) => (
|
||||
<MenuItem key={s} value={s}>
|
||||
{s}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Description</InputLabel>
|
||||
<TextField multiline minRows={2} value={form.description} onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}>
|
||||
Log Exception
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!resolveRow} onClose={() => setResolveRow(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Resolve Exception</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={2} sx={{ mt: 1 }}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select fullWidth value={resolveStatus} onChange={(e) => setResolveStatus(e.target.value)}>
|
||||
<MenuItem value="Resolved">Resolved</MenuItem>
|
||||
<MenuItem value="Closed">Closed</MenuItem>
|
||||
</Select>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Resolution Notes</InputLabel>
|
||||
<TextField multiline minRows={2} value={resolution} onChange={(e) => setResolution(e.target.value)} />
|
||||
</Stack>
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setResolveRow(null)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={() => resolveMutation.mutate()} sx={{ bgcolor: BRAND }}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Exceptions;
|
||||
374
src/pages/nearle/hubs/hubs.js
Normal file
374
src/pages/nearle/hubs/hubs.js
Normal file
@@ -0,0 +1,374 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { MdOutlineWarehouse, MdEdit, MdDeleteOutline, MdAdd, MdPlace } 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 { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import { getHubs, createHub, updateHub, deleteHub } from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
radiusField: 10,
|
||||
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 BRAND = '#C01227';
|
||||
const HUB_TYPES = ['sorting_center', 'delivery_hub'];
|
||||
|
||||
const emptyForm = {
|
||||
hubid: null,
|
||||
hubname: '',
|
||||
hubtype: 'delivery_hub',
|
||||
applocationid: '',
|
||||
contactno: '',
|
||||
address: '',
|
||||
latitude: '',
|
||||
longitude: '',
|
||||
pincode: '',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
const Hubs = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const { data: hubs = [], isLoading } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return hubs;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return hubs.filter((row) => [row.hubname, row.hubtype, row.address, row.pincode].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
|
||||
}, [hubs, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = hubs.length;
|
||||
const sorting = hubs.filter((h) => h.hubtype === 'sorting_center').length;
|
||||
const delivery = hubs.filter((h) => h.hubtype === 'delivery_hub').length;
|
||||
return { total, sorting, delivery };
|
||||
}, [hubs]);
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload) => (payload.hubid ? updateHub(payload.hubid, payload.data) : createHub(payload.data)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast(form.hubid ? 'Hub updated' : 'Hub created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-hubs'] });
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to save hub');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save hub')
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => deleteHub(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Hub deleted', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-hubs'] });
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to delete hub');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete hub')
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
hubid: row.hubid,
|
||||
hubname: row.hubname || '',
|
||||
hubtype: row.hubtype || 'delivery_hub',
|
||||
applocationid: row.applocationid ?? '',
|
||||
contactno: row.contactno || '',
|
||||
address: row.address || '',
|
||||
latitude: row.latitude ?? '',
|
||||
longitude: row.longitude ?? '',
|
||||
pincode: row.pincode || '',
|
||||
status: row.status || 'active'
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
if (window.confirm(`Delete hub "${row.hubname}"?`)) deleteMutation.mutate(row.hubid);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.hubname || !form.applocationid) {
|
||||
opentoast('Fill Hub Name and City (applocationid)');
|
||||
return;
|
||||
}
|
||||
const { hubid, ...data } = form;
|
||||
saveMutation.mutate({
|
||||
hubid,
|
||||
data: {
|
||||
...data,
|
||||
applocationid: Number(data.applocationid),
|
||||
latitude: Number(data.latitude) || 0,
|
||||
longitude: Number(data.longitude) || 0
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Hubs"
|
||||
subtitle="Live · Sorting Centers & Delivery Hubs"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
New Hub
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Total Hubs" value={stats.total} icon={<MdOutlineWarehouse size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Sorting Centers" value={stats.sorting} icon={<MdOutlineWarehouse size={20} />} color="#0ea5e9" />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Delivery Hubs" value={stats.delivery} icon={<MdPlace size={20} />} color="#10b981" />
|
||||
</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 }}>
|
||||
{hubs.length} total · {rows.length} shown
|
||||
</Typography>
|
||||
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
|
||||
<DebounceSearchBar
|
||||
value={searchword}
|
||||
onChange={setSearchword}
|
||||
onDebouncedChange={setDebouncedSearch}
|
||||
placeholder="Search hubs (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' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 900 : 960 }}>
|
||||
<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>Hub Name</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>City</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell>Pincode</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={6} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdOutlineWarehouse size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No hubs to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.hubid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{row.hubname}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{row.hubtype === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}</TableCell>
|
||||
<TableCell>{row.applocationid ?? '—'}</TableCell>
|
||||
<TableCell>{row.address || '—'}</TableCell>
|
||||
<TableCell>{row.pincode || '—'}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Edit hub">
|
||||
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
|
||||
<MdEdit size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete hub">
|
||||
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
|
||||
<MdDeleteOutline size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{form.hubid ? 'Edit Hub' : 'New Hub'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Hub Name</InputLabel>
|
||||
<TextField value={form.hubname} onChange={(e) => setForm((f) => ({ ...f, hubname: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Type</InputLabel>
|
||||
<Select fullWidth value={form.hubtype} onChange={(e) => setForm((f) => ({ ...f, hubtype: e.target.value }))}>
|
||||
{HUB_TYPES.map((t) => (
|
||||
<MenuItem key={t} value={t}>
|
||||
{t === 'sorting_center' ? 'Sorting Center' : 'Delivery Hub'}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>City (applocationid)</InputLabel>
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.applocationid}
|
||||
onChange={(e) => setForm((f) => ({ ...f, applocationid: e.target.value }))}
|
||||
helperText="Read the correct id from GET /admin/hubs rather than guessing"
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Contact No</InputLabel>
|
||||
<TextField value={form.contactno} onChange={(e) => setForm((f) => ({ ...f, contactno: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Address</InputLabel>
|
||||
<TextField value={form.address} onChange={(e) => setForm((f) => ({ ...f, address: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Pincode</InputLabel>
|
||||
<TextField value={form.pincode} onChange={(e) => setForm((f) => ({ ...f, pincode: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Latitude</InputLabel>
|
||||
<TextField type="number" value={form.latitude} onChange={(e) => setForm((f) => ({ ...f, latitude: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Longitude</InputLabel>
|
||||
<TextField type="number" value={form.longitude} onChange={(e) => setForm((f) => ({ ...f, longitude: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="inactive">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
|
||||
{form.hubid ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Hubs;
|
||||
328
src/pages/nearle/tripsheets/tripsheets.js
Normal file
328
src/pages/nearle/tripsheets/tripsheets.js
Normal file
@@ -0,0 +1,328 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { MdOutlineLocalShipping, MdAdd, MdPlaylistAdd, MdOutlineFlightTakeoff, MdOutlineFlightLand } from 'react-icons/md';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import Loader from 'components/Loader';
|
||||
import PageHeader from 'components/nearle_components/PageHeader';
|
||||
import StatCard from 'components/nearle_components/StatCard';
|
||||
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import {
|
||||
getTripsheets,
|
||||
createTripsheet,
|
||||
addTripsheetItem,
|
||||
dispatchTripsheet,
|
||||
arriveTripsheet,
|
||||
getHubs,
|
||||
getVehicles,
|
||||
getMilers
|
||||
} from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
// ============================================================================
|
||||
// Hub-to-hub transport. GET /admin/tripsheets response shape isn't documented
|
||||
// beyond the route existing (Status: Built in express-console-api.md) — field
|
||||
// bindings below are defensive. POST /admin/tripsheets body IS confirmed:
|
||||
// { sourcehubid, destinationhubid, vehicleid, driveruserid }.
|
||||
// ============================================================================
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
textPrimary: '#0f172a',
|
||||
textSecondary: '#64748b',
|
||||
textMuted: '#94a3b8',
|
||||
borderSubtle: '#e2e8f0',
|
||||
divider: '#f1f5f9',
|
||||
surface: '#ffffff',
|
||||
surfaceAlt: '#f8fafc'
|
||||
};
|
||||
const a = (c, suffix) => `${c}${suffix}`;
|
||||
const soft = (c) => a(c, '18');
|
||||
const BRAND = '#C01227';
|
||||
|
||||
const emptyForm = { sourceHub: null, destinationHub: null, vehicle: null, driver: null };
|
||||
|
||||
const Tripsheets = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [itemDialogTrip, setItemDialogTrip] = useState(null);
|
||||
const [itemConsignmentId, setItemConsignmentId] = useState('');
|
||||
|
||||
const { data: tripsheets = [], isLoading } = useQuery({ queryKey: ['admin-tripsheets'], queryFn: getTripsheets });
|
||||
const { data: hubs = [] } = useQuery({ queryKey: ['admin-hubs'], queryFn: getHubs });
|
||||
const { data: vehicles = [] } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
|
||||
const { data: milers = [] } = useQuery({ queryKey: ['admin-milers-for-trips'], queryFn: getMilers });
|
||||
|
||||
const hubMap = useMemo(() => new Map((hubs || []).map((h) => [h.hubid, h])), [hubs]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = tripsheets.length;
|
||||
const dispatched = tripsheets.filter((t) => String(t.status).toLowerCase().includes('dispatch')).length;
|
||||
return { total, dispatched };
|
||||
}, [tripsheets]);
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (data) => createTripsheet(data),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Tripsheet created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
|
||||
setDialogOpen(false);
|
||||
setForm(emptyForm);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to create tripsheet');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to create tripsheet')
|
||||
});
|
||||
|
||||
const addItemMutation = useMutation({
|
||||
mutationFn: ({ id, consignmentid }) => addTripsheetItem(id, consignmentid),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Item added', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
|
||||
setItemConsignmentId('');
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to add item');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to add item')
|
||||
});
|
||||
|
||||
const lifecycleMutation = useMutation({
|
||||
mutationFn: ({ action, id }) => (action === 'dispatch' ? dispatchTripsheet(id) : arriveTripsheet(id)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Tripsheet updated', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-tripsheets'] });
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to update tripsheet');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to update tripsheet')
|
||||
});
|
||||
|
||||
const handleCreate = () => {
|
||||
if (!form.sourceHub || !form.destinationHub || !form.vehicle) {
|
||||
opentoast('Choose source hub, destination hub and vehicle');
|
||||
return;
|
||||
}
|
||||
createMutation.mutate({
|
||||
sourcehubid: form.sourceHub.hubid,
|
||||
destinationhubid: form.destinationHub.hubid,
|
||||
vehicleid: form.vehicle.vehicleid,
|
||||
driveruserid: form.driver?.userid
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || createMutation.isPending || lifecycleMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Tripsheets"
|
||||
subtitle="Live · Hub-to-Hub Transport"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={() => setDialogOpen(true)} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
New Tripsheet
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={6}>
|
||||
<StatCard title="Total Tripsheets" value={stats.total} icon={<MdOutlineLocalShipping size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<StatCard title="Dispatched" value={stats.dispatched} icon={<MdOutlineFlightTakeoff size={20} />} color="#14b8a6" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<Paper elevation={0} sx={{ border: '1px solid', borderColor: DT.borderSubtle, borderRadius: DT.radiusCard / 8 + 'px', overflow: 'hidden', background: '#fff' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 860 : 920 }}>
|
||||
<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>Source Hub</TableCell>
|
||||
<TableCell>Destination Hub</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={3} />}
|
||||
{tripsheets.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdOutlineLocalShipping size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No tripsheets to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
tripsheets.map((row, index) => (
|
||||
<TableRow key={row.tripsheetid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>{hubMap.get(row.sourcehubid)?.hubname || (row.sourcehubid ? `Hub #${row.sourcehubid}` : '—')}</TableCell>
|
||||
<TableCell>{hubMap.get(row.destinationhubid)?.hubname || (row.destinationhubid ? `Hub #${row.destinationhubid}` : '—')}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Add item">
|
||||
<IconButton size="small" onClick={() => setItemDialogTrip(row)} sx={{ color: BRAND }}>
|
||||
<MdPlaylistAdd size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Dispatch">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => lifecycleMutation.mutate({ action: 'dispatch', id: row.tripsheetid })}
|
||||
sx={{ color: '#14b8a6' }}
|
||||
>
|
||||
<MdOutlineFlightTakeoff size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Arrive">
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => lifecycleMutation.mutate({ action: 'arrive', id: row.tripsheetid })}
|
||||
sx={{ color: '#10b981' }}
|
||||
>
|
||||
<MdOutlineFlightLand size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>New Tripsheet</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Source Hub</InputLabel>
|
||||
<Autocomplete
|
||||
options={hubs || []}
|
||||
getOptionLabel={(option) => option.hubname || ''}
|
||||
value={form.sourceHub}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, sourceHub: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose source hub" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Destination Hub</InputLabel>
|
||||
<Autocomplete
|
||||
options={hubs || []}
|
||||
getOptionLabel={(option) => option.hubname || ''}
|
||||
value={form.destinationHub}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, destinationHub: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose destination hub" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle</InputLabel>
|
||||
<Autocomplete
|
||||
options={vehicles || []}
|
||||
getOptionLabel={(option) => option.vehicleno || ''}
|
||||
value={form.vehicle}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, vehicle: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose vehicle" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Driver (optional)</InputLabel>
|
||||
<Autocomplete
|
||||
options={milers || []}
|
||||
getOptionLabel={(option) => option.displayname || option.authname || ''}
|
||||
value={form.driver}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, driver: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose driver" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleCreate} sx={{ bgcolor: BRAND }}>
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!itemDialogTrip} onClose={() => setItemDialogTrip(null)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>Add Item to Tripsheet</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1} sx={{ mt: 1 }}>
|
||||
<InputLabel>Consignment ID</InputLabel>
|
||||
<TextField value={itemConsignmentId} onChange={(e) => setItemConsignmentId(e.target.value)} autoFocus />
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setItemDialogTrip(null)}>Cancel</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!itemConsignmentId}
|
||||
onClick={() => addItemMutation.mutate({ id: itemDialogTrip.tripsheetid, consignmentid: itemConsignmentId })}
|
||||
sx={{ bgcolor: BRAND }}
|
||||
>
|
||||
Add Item
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Tripsheets;
|
||||
367
src/pages/nearle/vehicles/vehicles.js
Normal file
367
src/pages/nearle/vehicles/vehicles.js
Normal file
@@ -0,0 +1,367 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import {
|
||||
Autocomplete,
|
||||
Avatar,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
useMediaQuery,
|
||||
useTheme
|
||||
} from '@mui/material';
|
||||
import { MdDirectionsCar, MdEdit, MdDeleteOutline, MdAdd, MdBatteryStd } 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 { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
||||
import { getVehicles, createVehicle, updateVehicle, deleteVehicle, getPartners } from 'pages/api/doormileApi';
|
||||
import { enqueueSnackbar } from 'notistack';
|
||||
|
||||
const DT = {
|
||||
radiusCard: 14,
|
||||
radiusField: 10,
|
||||
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 BRAND = '#C01227';
|
||||
const VEHICLE_TYPES = ['Bike', 'Scooter', 'Bicycle', 'Car', 'Van'];
|
||||
|
||||
const emptyForm = {
|
||||
vehicleid: null,
|
||||
vehicleno: '',
|
||||
vehicletype: 'Bike',
|
||||
maxweight: '',
|
||||
maxvolume: '',
|
||||
partner: null,
|
||||
batterypercentage: '',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
const Vehicles = () => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
||||
const queryClient = useQueryClient();
|
||||
const [searchword, setSearchword] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
|
||||
const { data: vehicles = [], isLoading } = useQuery({ queryKey: ['admin-vehicles'], queryFn: getVehicles });
|
||||
const { data: partners = [] } = useQuery({ queryKey: ['admin-partners'], queryFn: getPartners });
|
||||
const partnerMap = useMemo(() => new Map((partners || []).map((p) => [p.partnerid, p])), [partners]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
if (!debouncedSearch) return vehicles;
|
||||
const q = debouncedSearch.toLowerCase().trim();
|
||||
return vehicles.filter((row) => [row.vehicleno, row.vehicletype, row.status].filter(Boolean).some((f) => String(f).toLowerCase().includes(q)));
|
||||
}, [vehicles, debouncedSearch]);
|
||||
|
||||
const stats = useMemo(() => {
|
||||
const total = vehicles.length;
|
||||
const active = vehicles.filter((v) => String(v.status).toLowerCase() === 'active').length;
|
||||
const lowBattery = vehicles.filter((v) => Number(v.batterypercentage) > 0 && Number(v.batterypercentage) < 20).length;
|
||||
return { total, active, lowBattery };
|
||||
}, [vehicles]);
|
||||
|
||||
const opentoast = (message, variant = 'error') =>
|
||||
enqueueSnackbar(message, { variant, anchorOrigin: { vertical: 'top', horizontal: 'right' }, autoHideDuration: 2000 });
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: (payload) => (payload.vehicleid ? updateVehicle(payload.vehicleid, payload.data) : createVehicle(payload.data)),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast(form.vehicleid ? 'Vehicle updated' : 'Vehicle created', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-vehicles'] });
|
||||
setDialogOpen(false);
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to save vehicle');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to save vehicle')
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id) => deleteVehicle(id),
|
||||
onSuccess: (res) => {
|
||||
if (res.success) {
|
||||
opentoast('Vehicle deleted', 'success');
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-vehicles'] });
|
||||
} else {
|
||||
opentoast(res.message || 'Failed to delete vehicle');
|
||||
}
|
||||
},
|
||||
onError: (err) => opentoast(err.response?.data?.message || err.message || 'Failed to delete vehicle')
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setForm(emptyForm);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (row) => {
|
||||
setForm({
|
||||
vehicleid: row.vehicleid,
|
||||
vehicleno: row.vehicleno || '',
|
||||
vehicletype: row.vehicletype || 'Bike',
|
||||
maxweight: row.maxweight ?? '',
|
||||
maxvolume: row.maxvolume ?? '',
|
||||
partner: partnerMap.get(row.partnerid) || null,
|
||||
batterypercentage: row.batterypercentage ?? '',
|
||||
status: row.status || 'active'
|
||||
});
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (row) => {
|
||||
if (window.confirm(`Delete vehicle "${row.vehicleno}"?`)) deleteMutation.mutate(row.vehicleid);
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!form.vehicleno) {
|
||||
opentoast('Fill Vehicle No');
|
||||
return;
|
||||
}
|
||||
const { vehicleid, partner, ...rest } = form;
|
||||
saveMutation.mutate({
|
||||
vehicleid,
|
||||
data: {
|
||||
...rest,
|
||||
maxweight: Number(rest.maxweight) || 0,
|
||||
maxvolume: Number(rest.maxvolume) || 0,
|
||||
batterypercentage: Number(rest.batterypercentage) || 0,
|
||||
partnerid: partner?.partnerid
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{(isLoading || saveMutation.isPending || deleteMutation.isPending) && <Loader />}
|
||||
|
||||
<PageHeader
|
||||
title="Vehicles"
|
||||
subtitle="Live · Fleet Directory"
|
||||
live
|
||||
action={
|
||||
<Button variant="contained" startIcon={<MdAdd size={16} />} onClick={openCreate} sx={{ bgcolor: BRAND, whiteSpace: 'nowrap' }}>
|
||||
New Vehicle
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Total Vehicles" value={stats.total} icon={<MdDirectionsCar size={20} />} color={BRAND} />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Active" value={stats.active} icon={<MdDirectionsCar size={20} />} color="#10b981" />
|
||||
</Grid>
|
||||
<Grid item xs={4}>
|
||||
<StatCard title="Low Battery" value={stats.lowBattery} icon={<MdBatteryStd size={20} />} color="#f59e0b" />
|
||||
</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 }}>
|
||||
{vehicles.length} total · {rows.length} shown
|
||||
</Typography>
|
||||
<Box sx={{ width: { xs: '100%', sm: 280 } }}>
|
||||
<DebounceSearchBar
|
||||
value={searchword}
|
||||
onChange={setSearchword}
|
||||
onDebouncedChange={setDebouncedSearch}
|
||||
placeholder="Search vehicles (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' }}>
|
||||
<TableContainer sx={{ maxHeight: { xs: 'calc(100vh - 260px)', md: 'calc(100vh - 190px)' } }}>
|
||||
<Table stickyHeader sx={{ minWidth: isMobile ? 900 : 960 }}>
|
||||
<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>Vehicle No</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Partner</TableCell>
|
||||
<TableCell align="center">Max Weight</TableCell>
|
||||
<TableCell align="center">Battery</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{isLoading && <OrdersTableSkeleton col={6} />}
|
||||
{rows.length === 0 && !isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ py: 6 }}>
|
||||
<Stack alignItems="center" spacing={1.5}>
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
||||
<MdDirectionsCar size={28} />
|
||||
</Avatar>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
No vehicles to show
|
||||
</Typography>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
rows.map((row, index) => (
|
||||
<TableRow key={row.vehicleid || index} sx={{ '& td': { borderBottom: `1px solid ${DT.divider}` }, '&:hover': { backgroundColor: DT.surfaceAlt } }}>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>
|
||||
{row.vehicleno}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>{row.vehicletype || '—'}</TableCell>
|
||||
<TableCell>{partnerMap.get(row.partnerid)?.partnername || (row.partnerid ? `Partner #${row.partnerid}` : '—')}</TableCell>
|
||||
<TableCell align="center">{row.maxweight ?? '—'}</TableCell>
|
||||
<TableCell align="center">{row.batterypercentage != null ? `${row.batterypercentage}%` : '—'}</TableCell>
|
||||
<TableCell>{row.status || '—'}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Stack direction="row" spacing={0.5} justifyContent="flex-end">
|
||||
<Tooltip title="Edit vehicle">
|
||||
<IconButton size="small" onClick={() => openEdit(row)} sx={{ color: BRAND }}>
|
||||
<MdEdit size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete vehicle">
|
||||
<IconButton size="small" onClick={() => handleDelete(row)} sx={{ color: '#ef4444' }}>
|
||||
<MdDeleteOutline size={16} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
|
||||
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 700 }}>{form.vehicleid ? 'Edit Vehicle' : 'New Vehicle'}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Grid container spacing={2} sx={{ mt: 0.5 }}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle No</InputLabel>
|
||||
<TextField value={form.vehicleno} onChange={(e) => setForm((f) => ({ ...f, vehicleno: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Vehicle Type</InputLabel>
|
||||
<Select fullWidth value={form.vehicletype} onChange={(e) => setForm((f) => ({ ...f, vehicletype: e.target.value }))}>
|
||||
{VEHICLE_TYPES.map((v) => (
|
||||
<MenuItem key={v} value={v}>
|
||||
{v}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Partner</InputLabel>
|
||||
<Autocomplete
|
||||
options={partners || []}
|
||||
getOptionLabel={(option) => option.partnername || ''}
|
||||
value={form.partner}
|
||||
onChange={(e, value) => setForm((f) => ({ ...f, partner: value }))}
|
||||
renderInput={(params) => <TextField {...params} placeholder="Choose partner" />}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select fullWidth value={form.status} onChange={(e) => setForm((f) => ({ ...f, status: e.target.value }))}>
|
||||
<MenuItem value="active">Active</MenuItem>
|
||||
<MenuItem value="inactive">Inactive</MenuItem>
|
||||
</Select>
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Max Weight (kg)</InputLabel>
|
||||
<TextField type="number" value={form.maxweight} onChange={(e) => setForm((f) => ({ ...f, maxweight: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Max Volume</InputLabel>
|
||||
<TextField type="number" value={form.maxvolume} onChange={(e) => setForm((f) => ({ ...f, maxvolume: e.target.value }))} />
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={4}>
|
||||
<Stack spacing={1}>
|
||||
<InputLabel>Battery %</InputLabel>
|
||||
<TextField
|
||||
type="number"
|
||||
value={form.batterypercentage}
|
||||
onChange={(e) => setForm((f) => ({ ...f, batterypercentage: e.target.value }))}
|
||||
/>
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, pb: 2 }}>
|
||||
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSave} sx={{ bgcolor: BRAND }}>
|
||||
{form.vehicleid ? 'Save Changes' : 'Create'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Vehicles;
|
||||
Reference in New Issue
Block a user