Finalize CRM module: bookings, pricing, survey with full UI integration and validation
This commit is contained in:
895
src/pages/survey/Survey.jsx
Normal file
895
src/pages/survey/Survey.jsx
Normal file
@@ -0,0 +1,895 @@
|
||||
import { useState, useEffect, Fragment } from 'react';
|
||||
import { fetchUsers, deleteCompetitorBranch } from '@/utils/apiClient';
|
||||
import {
|
||||
Card, Box, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
Typography, TextField, InputAdornment, IconButton, Collapse, TablePagination, Stack, CircularProgress, alpha,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions, MenuItem, Alert, Autocomplete, Snackbar
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||
import AssignmentOutlinedIcon from '@mui/icons-material/AssignmentOutlined';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import { Avatar, Button, Chip, Grid } from '@mui/material';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE || 'https://api.doormile.com/api/v1';
|
||||
|
||||
const getHeaders = () => {
|
||||
const token = localStorage.getItem('auth_token');
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||
};
|
||||
};
|
||||
|
||||
const FIELD_LABEL_SX = { textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700, color: 'grey.700' };
|
||||
|
||||
function Pill({ label, color = 'default' }) {
|
||||
if (!label) return <Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.500' }}>—</Typography>;
|
||||
return (
|
||||
<Chip size="small" label={label} sx={{ fontWeight: 600, ...(color === 'default' ? { bgcolor: 'grey.100', color: 'grey.700' } : { bgcolor: `${color}.lighter`, color: `${color}.dark` }) }} />
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary" sx={FIELD_LABEL_SX}>{label}</Typography>
|
||||
<Box sx={{ mt: 0.5 }}>{children}</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MagicStatCard({ title, value, icon: Icon, gradient, percentage }) {
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
borderRadius: 3.5,
|
||||
p: 3,
|
||||
color: 'white',
|
||||
background: gradient,
|
||||
boxShadow: '0 10px 30px rgba(0,0,0,0.08)',
|
||||
transition: 'box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.12)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Box sx={{ position: 'relative', zIndex: 2 }}>
|
||||
<Stack direction="row" justifyContent="space-between" alignItems="center">
|
||||
<Typography variant="overline" sx={{ fontWeight: 700, letterSpacing: 1.2, opacity: 0.9 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Box sx={{ width: 36, height: 36, borderRadius: '50%', bgcolor: 'rgba(255,255,255,0.2)', display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(4px)' }}>
|
||||
<Icon sx={{ fontSize: 20 }} />
|
||||
</Box>
|
||||
</Stack>
|
||||
<Stack direction="row" alignItems="baseline" spacing={1.5} sx={{ mt: 2 }}>
|
||||
<Typography variant="h3" sx={{ fontWeight: 800, lineHeight: 1 }}>
|
||||
{value}
|
||||
</Typography>
|
||||
{percentage && (
|
||||
<Chip size="small" label={percentage} sx={{ height: 22, bgcolor: 'rgba(255,255,255,0.25)', color: 'white', fontWeight: 700, borderRadius: 1.5, backdropFilter: 'blur(4px)' }} />
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
<Icon
|
||||
sx={{
|
||||
position: 'absolute',
|
||||
right: -24,
|
||||
bottom: -24,
|
||||
fontSize: 160,
|
||||
opacity: 0.1,
|
||||
zIndex: 1,
|
||||
transform: 'rotate(-15deg)'
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionCard({ icon: Icon, title, children }) {
|
||||
return (
|
||||
<Box sx={{ height: '100%', borderRadius: 2, border: 1, borderColor: 'divider', bgcolor: 'background.paper', overflow: 'hidden' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ px: 2, py: 1.25, borderBottom: 1, borderColor: 'divider', bgcolor: alpha('#c01227', 0.08) }}>
|
||||
<Icon sx={{ fontSize: 18, color: '#c01227' }} />
|
||||
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography>
|
||||
</Stack>
|
||||
<Stack spacing={2} sx={{ p: 2 }}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
async function apiFetchSurveys() {
|
||||
const res = await fetch(`${API_BASE}/admin/competitor-branches?limit=1000`, { headers: getHeaders() });
|
||||
if (!res.ok) throw new Error('Failed to fetch surveys');
|
||||
const json = await res.json();
|
||||
const data = Array.isArray(json) ? json : (json.data || []);
|
||||
return { data, total: data.length };
|
||||
}
|
||||
|
||||
async function apiSaveSurvey(data) {
|
||||
const isUpdate = !!data.id;
|
||||
const url = isUpdate ? `${API_BASE}/admin/competitor-branches/${data.id}` : `${API_BASE}/admin/competitor-branches`;
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to save survey');
|
||||
}
|
||||
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
async function apiSavePricing(data) {
|
||||
const isUpdate = !!data.id;
|
||||
const url = isUpdate ? `${API_BASE}/admin/carrier-pricing/${data.id}` : `${API_BASE}/admin/carrier-pricing`;
|
||||
const method = isUpdate ? 'PUT' : 'POST';
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: getHeaders(),
|
||||
body: JSON.stringify({
|
||||
company: data.company,
|
||||
weight_slab: data.weight_slab,
|
||||
zone: data.zone || '',
|
||||
service_type: data.service_type || '',
|
||||
rate: String(data.rate)
|
||||
})
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || err.message || 'Failed to save pricing');
|
||||
}
|
||||
return res.status === 204 ? {} : res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
const ZONES = ['Local / Same', 'Within Tamilnadu', 'Interstate India'];
|
||||
const WEIGHT_SLABS = ['< 500g', '500g - 1kg', '1kg - 2kg', '2kg - 5kg', '> 5kg'];
|
||||
|
||||
function SurveyFormDialog({ open, onClose, onSave, initialData }) {
|
||||
const [formData, setFormData] = useState({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setFormData(initialData ? {
|
||||
...initialData,
|
||||
pincodes: initialData.pincodes ? initialData.pincodes.split(',').map(s => s.trim()).filter(Boolean) : []
|
||||
} : {
|
||||
company: '', area: '', phone: '', rate_per_kg: '',
|
||||
offers_pickup: 'no', offers_drop: 'no', packing_charge: '',
|
||||
time_in_days: '', plus_code: '', address: '', frequency: '',
|
||||
pincodes: [],
|
||||
slabs: []
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
}, [open, initialData]);
|
||||
|
||||
const handleChange = (field) => (e) => {
|
||||
setFormData(prev => ({ ...prev, [field]: e.target.value }));
|
||||
};
|
||||
|
||||
const handleSlabChange = (index, field) => (e) => {
|
||||
const newSlabs = [...(formData.slabs || [])];
|
||||
newSlabs[index] = { ...newSlabs[index], [field]: e.target.value };
|
||||
setFormData(prev => ({ ...prev, slabs: newSlabs }));
|
||||
};
|
||||
|
||||
const addSlab = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
slabs: [...(prev.slabs || []), { weight_slab: '', zone: '', service_type: '', rate: '' }]
|
||||
}));
|
||||
};
|
||||
|
||||
const removeSlab = (index) => {
|
||||
const newSlabs = [...(formData.slabs || [])];
|
||||
newSlabs.splice(index, 1);
|
||||
setFormData(prev => ({ ...prev, slabs: newSlabs }));
|
||||
};
|
||||
|
||||
const handlePincodeChange = (index) => (e) => {
|
||||
const newPincodes = [...(formData.pincodes || [])];
|
||||
newPincodes[index] = e.target.value;
|
||||
setFormData(prev => ({ ...prev, pincodes: newPincodes }));
|
||||
};
|
||||
|
||||
const addPincode = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
pincodes: [...(prev.pincodes || []), '']
|
||||
}));
|
||||
};
|
||||
|
||||
const removePincode = (index) => {
|
||||
const newPincodes = [...(formData.pincodes || [])];
|
||||
newPincodes.splice(index, 1);
|
||||
setFormData(prev => ({ ...prev, pincodes: newPincodes }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (formData.pincodes && formData.pincodes.length > 0) {
|
||||
const invalidPincodes = formData.pincodes.filter(p => p.trim() && !/^\d{6}$/.test(p.trim()));
|
||||
if (invalidPincodes.length > 0) {
|
||||
throw new Error(`Invalid pincodes detected: ${invalidPincodes.join(', ')}. All pincodes must be exactly 6 numeric digits.`);
|
||||
}
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...(formData.id ? { id: formData.id } : {}),
|
||||
company: formData.company || '',
|
||||
area: formData.area || '',
|
||||
phone: formData.phone || '',
|
||||
rate_per_kg: formData.rate_per_kg || '',
|
||||
offers_pickup: formData.offers_pickup || 'no',
|
||||
offers_drop: formData.offers_drop || 'no',
|
||||
packing_charge: formData.packing_charge || '',
|
||||
time_in_days: formData.time_in_days || '',
|
||||
plus_code: formData.plus_code || '',
|
||||
address: formData.address || '',
|
||||
frequency: formData.frequency || '',
|
||||
pincodes: formData.pincodes ? formData.pincodes.join(',') : '',
|
||||
};
|
||||
await apiSaveSurvey(payload);
|
||||
|
||||
if (formData.slabs && formData.slabs.length > 0 && formData.company) {
|
||||
try {
|
||||
await Promise.all(formData.slabs.map(slab => {
|
||||
if (slab.weight_slab && slab.rate) {
|
||||
return apiSavePricing({
|
||||
company: formData.company,
|
||||
weight_slab: slab.weight_slab,
|
||||
zone: slab.zone || '',
|
||||
service_type: slab.service_type || '',
|
||||
rate: slab.rate
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error("Failed to save pricing slabs", err);
|
||||
// Continue anyway since survey was saved
|
||||
}
|
||||
}
|
||||
|
||||
onSave();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err.message || 'Failed to save. Please try again.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Snackbar
|
||||
open={!!error}
|
||||
autoHideDuration={6000}
|
||||
onClose={() => setError(null)}
|
||||
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
|
||||
sx={{ zIndex: 9999 }}
|
||||
>
|
||||
<Alert onClose={() => setError(null)} severity="error" variant="filled" sx={{ width: '100%', boxShadow: 3 }}>
|
||||
{error}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
|
||||
<Dialog open={open} onClose={saving ? undefined : onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle sx={{ fontWeight: 800 }}>{initialData ? 'Edit Survey Record' : 'Add New Survey'}</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Company" value={formData.company || ''} onChange={handleChange('company')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Area / Zone" value={formData.area || ''} onChange={handleChange('area')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Phone" value={formData.phone || ''} onChange={handleChange('phone')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Rate Per KG" value={formData.rate_per_kg || ''} onChange={handleChange('rate_per_kg')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Offers Pickup" value={formData.offers_pickup || 'no'} onChange={handleChange('offers_pickup')} size="small">
|
||||
<MenuItem value="yes">Yes</MenuItem>
|
||||
<MenuItem value="no">No</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Offers Drop" value={formData.offers_drop || 'no'} onChange={handleChange('offers_drop')} size="small">
|
||||
<MenuItem value="yes">Yes</MenuItem>
|
||||
<MenuItem value="no">No</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Time in Days" value={formData.time_in_days || ''} onChange={handleChange('time_in_days')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField fullWidth label="Packing Charge" value={formData.packing_charge || ''} onChange={handleChange('packing_charge')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12} sm={6}>
|
||||
<TextField select fullWidth label="Frequency" value={formData.frequency || ''} onChange={handleChange('frequency')} size="small">
|
||||
<MenuItem value="Daily">Daily</MenuItem>
|
||||
<MenuItem value="Weekly">Weekly</MenuItem>
|
||||
<MenuItem value="Bi-weekly">Bi-weekly</MenuItem>
|
||||
<MenuItem value="Monthly">Monthly</MenuItem>
|
||||
<MenuItem value="On-demand">On-demand</MenuItem>
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth label="Plus Code" value={formData.plus_code || ''} onChange={handleChange('plus_code')} size="small" />
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ mt: 1, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', letterSpacing: 0.5 }}>SERVICEABLE PINCODES</Typography>
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addPincode} sx={{ fontWeight: 600 }}>Add Pincode</Button>
|
||||
</Box>
|
||||
{(formData.pincodes || []).map((pincode, index) => (
|
||||
<Grid container spacing={1.5} key={index} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||
<Grid item xs={10} sm={11}>
|
||||
<TextField
|
||||
fullWidth
|
||||
label={`Pincode ${index + 1}`}
|
||||
value={pincode}
|
||||
onChange={handlePincodeChange(index)}
|
||||
size="small"
|
||||
placeholder="e.g. 600001"
|
||||
inputProps={{ maxLength: 6 }}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={2} sm={1}>
|
||||
<IconButton size="small" color="error" onClick={() => removePincode(index)}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
{(!formData.pincodes || formData.pincodes.length === 0) && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2, bgcolor: 'grey.50', borderRadius: 1, border: '1px dashed', borderColor: 'grey.300' }}>
|
||||
Click 'Add Pincode' to assign locations to this competitor.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid item xs={12}>
|
||||
<TextField fullWidth multiline rows={2} label="Address" value={formData.address || ''} onChange={handleChange('address')} size="small" />
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ mt: 1, pt: 2, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: 'primary.main', letterSpacing: 0.5 }}>PRICING SLABS (OPTIONAL)</Typography>
|
||||
<Button size="small" startIcon={<AddIcon />} onClick={addSlab} sx={{ fontWeight: 600 }}>Add Slab</Button>
|
||||
</Box>
|
||||
{(formData.slabs || []).map((slab, index) => (
|
||||
<Grid container spacing={1.5} key={index} sx={{ mb: 1.5, alignItems: 'center' }}>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField select fullWidth label="Weight Slab" value={slab.weight_slab || ''} onChange={handleSlabChange(index, 'weight_slab')} size="small">
|
||||
{WEIGHT_SLABS.map(s => <MenuItem key={s} value={s}>{s}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField select fullWidth label="Zone" value={slab.zone || ''} onChange={handleSlabChange(index, 'zone')} size="small">
|
||||
{ZONES.map(z => <MenuItem key={z} value={z}>{z}</MenuItem>)}
|
||||
</TextField>
|
||||
</Grid>
|
||||
<Grid item xs={6} sm={3}>
|
||||
<TextField fullWidth label="Service Type" value={slab.service_type || ''} onChange={handleSlabChange(index, 'service_type')} size="small" placeholder="e.g. Air" />
|
||||
</Grid>
|
||||
<Grid item xs={4} sm={2}>
|
||||
<TextField fullWidth label="Rate (₹)" value={slab.rate || ''} onChange={handleSlabChange(index, 'rate')} size="small" placeholder="e.g. 25" />
|
||||
</Grid>
|
||||
<Grid item xs={2} sm={1}>
|
||||
<IconButton size="small" color="error" onClick={() => removeSlab(index)}>
|
||||
<CloseIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
))}
|
||||
{(!formData.slabs || formData.slabs.length === 0) && (
|
||||
<Typography variant="body2" color="text.secondary" sx={{ textAlign: 'center', py: 2, bgcolor: 'grey.50', borderRadius: 1, border: '1px dashed', borderColor: 'grey.300' }}>
|
||||
Click 'Add Slab' to add pricing entries for this competitor.
|
||||
</Typography>
|
||||
)}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ p: 2, bgcolor: 'grey.50' }}>
|
||||
<Button onClick={onClose} disabled={saving} color="inherit">Cancel</Button>
|
||||
<Button onClick={handleSave} disabled={saving} variant="contained" sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2 }}>
|
||||
{saving ? 'Saving...' : 'Save Record'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchCard({ row, onEdit, onDelete, users }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const creator = users.find(u => u.id === row.created_by);
|
||||
const updater = users.find(u => u.id === row.updated_by);
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 2, borderRadius: 2.5, border: '1px solid', borderColor: 'divider', bgcolor: 'background.paper', transition: 'border-color 0.2s ease, box-shadow 0.2s ease', '&:hover': { borderColor: '#c01227', boxShadow: '0 4px 12px rgba(192, 18, 39, 0.05)' } }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
|
||||
{/* Left: Location & Contact */}
|
||||
<Stack direction="row" spacing={2} alignItems="center" sx={{ minWidth: 240 }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: 2, bgcolor: alpha('#c01227', 0.05) }}>
|
||||
<PlaceOutlinedIcon sx={{ color: '#c01227' }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.900', lineHeight: 1.2 }}>
|
||||
{row.area || 'Unknown Area'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'grey.600', display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5 }}>
|
||||
<PhoneOutlinedIcon sx={{ fontSize: 14 }} /> {row.phone || '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Middle: Rates */}
|
||||
<Stack direction="row" spacing={4} sx={{ flex: 1 }} justifyContent={{ xs: 'flex-start', md: 'center' }}>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 0.5, display: 'block', mb: 0.5 }}>RATE PER KG</Typography>
|
||||
<Pill label={row.rate_per_kg} color="success" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="caption" sx={{ color: 'grey.500', fontWeight: 700, letterSpacing: 0.5, display: 'block', mb: 0.5 }}>LOGISTICS</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>
|
||||
{row.offers_pickup === 'yes' || row.offers_drop === 'yes' ? `${row.offers_pickup === 'yes' ? 'Pickup' : ''} ${row.offers_drop === 'yes' ? 'Drop' : ''}` : '—'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Right: Actions */}
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Button size="small" sx={{ borderRadius: 1.5, color: open ? '#c01227' : 'grey.700', bgcolor: open ? alpha('#c01227', 0.05) : 'transparent', '&:hover': { bgcolor: alpha('#c01227', 0.1), color: '#c01227' } }}>
|
||||
{open ? 'Hide Info' : 'More Info'}
|
||||
</Button>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }} sx={{ color: 'grey.400', '&:hover': { color: '#c01227', bgcolor: alpha('#c01227', 0.1) } }}>
|
||||
<EditOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }} sx={{ color: 'grey.400', '&:hover': { color: '#ef4444', bgcolor: alpha('#ef4444', 0.1) } }}>
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Stack>
|
||||
|
||||
</Stack>
|
||||
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ mt: 2.5, pt: 2.5, borderTop: '1px dashed', borderColor: 'divider' }}>
|
||||
<Grid container spacing={2} alignItems="stretch">
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={StorefrontOutlinedIcon} title="ENQUIRY DETAILS">
|
||||
<Field label="RATE PER KG">
|
||||
<Pill label={row.rate_per_kg} color="success" />
|
||||
</Field>
|
||||
<Field label="PICKUP">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: row.offers_pickup ? 'grey.800' : 'grey.500' }}>{row.offers_pickup || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="DROP">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: row.offers_drop ? 'grey.800' : 'grey.500' }}>{row.offers_drop || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="PACKING">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: row.packing_charge ? 'grey.800' : 'grey.500' }}>{row.packing_charge || '—'}</Typography>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={LocalShippingOutlinedIcon} title="LOGISTICS & OPS">
|
||||
<Field label="TIME IN DAYS">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: row.time_in_days ? 'grey.800' : 'grey.500' }}>{row.time_in_days || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="COMPANY">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.company || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="FREQUENCY">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.frequency || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="CONTACT NUMBER">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.phone || '—'}</Typography>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={PlaceOutlinedIcon} title="LOCATION & PINCODE">
|
||||
<Field label="AREA / ZONE">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{row.area || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="SERVICEABLE PINCODES">
|
||||
{row.pincodes ? (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mt: 0.5 }}>
|
||||
{row.pincodes.split(',').map((p, i) => (
|
||||
<Chip key={i} label={p.trim()} size="small" sx={{ fontSize: '0.7rem', height: 20 }} />
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.500' }}>—</Typography>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="PLUS CODE">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: row.plus_code ? 'grey.800' : 'grey.500' }}>{row.plus_code || '—'}</Typography>
|
||||
</Field>
|
||||
<Field label="FULL ADDRESS">
|
||||
<Stack spacing={1.5}>
|
||||
<Stack direction="row" spacing={1} sx={{ p: 1.5, borderRadius: 1.5, bgcolor: 'grey.50', border: 1, borderColor: 'divider' }}>
|
||||
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400', mt: '2px' }} />
|
||||
<Typography variant="body2" sx={{ color: 'grey.800', lineHeight: 1.5 }}>{row.address || '—'}</Typography>
|
||||
</Stack>
|
||||
{(row.address || row.plus_code) && (
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(row.plus_code ? row.plus_code + ' ' + (row.address||'') : row.address)}`}
|
||||
target="_blank" rel="noopener"
|
||||
size="small" variant="outlined" startIcon={<MapOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
alignSelf: 'flex-start',
|
||||
py: 0.5, px: 1.5, fontSize: '0.75rem', fontWeight: 600, borderRadius: 2,
|
||||
color: '#c01227', borderColor: alpha('#c01227', 0.5), bgcolor: alpha('#c01227', 0.05),
|
||||
'&:hover': { borderColor: '#c01227', bgcolor: alpha('#c01227', 0.1) }
|
||||
}}
|
||||
>
|
||||
View on map
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<SectionCard icon={AssignmentOutlinedIcon} title="RECORD METADATA">
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={6}>
|
||||
<Field label="CREATED BY">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>
|
||||
{creator ? creator.first_name : (row.created_by ? `User ID: ${row.created_by}` : 'System')}
|
||||
</Typography>
|
||||
</Field>
|
||||
</Grid>
|
||||
<Grid item xs={6}>
|
||||
<Field label="LAST EDITED BY">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>
|
||||
{updater ? updater.first_name : (row.updated_by ? `User ID: ${row.updated_by}` : '—')}
|
||||
</Typography>
|
||||
</Field>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function CompanyGroup({ companyName, branches, onEdit, onDelete, users }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
sx={{
|
||||
borderRadius: 3,
|
||||
border: '1px solid',
|
||||
borderColor: open ? '#c01227' : 'grey.200',
|
||||
p: { xs: 2.5, md: 3 },
|
||||
mb: 2.5,
|
||||
transition: 'border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
'&:hover': {
|
||||
borderColor: '#c01227',
|
||||
boxShadow: '0 8px 30px rgba(192, 18, 39, 0.08)',
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Stack
|
||||
direction={{ xs: 'column', md: 'row' }} spacing={3} alignItems={{ xs: 'flex-start', md: 'center' }} justifyContent="space-between"
|
||||
onClick={() => setOpen(!open)}
|
||||
sx={{ cursor: 'pointer', userSelect: 'none' }}
|
||||
>
|
||||
|
||||
{/* Left Side: Avatar & Basic Info */}
|
||||
<Stack direction="row" spacing={2.5} alignItems="center">
|
||||
<Avatar sx={{ width: 64, height: 64, bgcolor: alpha('#c01227', 0.1), color: '#c01227', fontWeight: 800, fontSize: '1.5rem', borderRadius: 2.5 }}>
|
||||
{(companyName || 'A')[0].toUpperCase()}
|
||||
</Avatar>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 800, lineHeight: 1.2, color: 'grey.900' }}>
|
||||
{companyName || 'Unknown Company'}
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.5, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
||||
<StorefrontOutlinedIcon sx={{ fontSize: 16 }} /> {branches.length} Location{branches.length !== 1 && 's'} Surveyed
|
||||
</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Right Side: Badges & Actions */}
|
||||
<Stack direction="row" spacing={3} alignItems="center">
|
||||
<Stack direction="row" spacing={1} sx={{ display: { xs: 'none', md: 'flex' } }}>
|
||||
<Chip size="small" label="First Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||
<Chip size="small" label="Mid Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||
<Chip size="small" label="Last Mile" sx={{ bgcolor: alpha('#c01227', 0.08), color: '#c01227', fontWeight: 700, borderRadius: 1.5 }} />
|
||||
</Stack>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<Button
|
||||
sx={{
|
||||
borderRadius: 2, py: 1, px: 3, fontWeight: 700,
|
||||
color: open ? '#c01227' : 'grey.700',
|
||||
bgcolor: open ? alpha('#c01227', 0.1) : 'grey.100',
|
||||
'&:hover': { bgcolor: alpha('#c01227', 0.15), color: '#c01227' }
|
||||
}}
|
||||
endIcon={open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
>
|
||||
{open ? 'Hide Locations' : 'View Locations'}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ mt: 3, pt: 3, borderTop: '2px dashed', borderColor: 'grey.200' }}>
|
||||
<Stack spacing={2}>
|
||||
{branches.map((branch, idx) => <BranchCard key={branch.id ?? idx} row={branch} onEdit={onEdit} onDelete={onDelete} users={users} />)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Survey() {
|
||||
const [data, setData] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rowsPerPage, setRowsPerPage] = useState(5);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingRecord, setEditingRecord] = useState(null);
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState(null);
|
||||
|
||||
const [users, setUsers] = useState([]);
|
||||
|
||||
const loadData = () => {
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
apiFetchSurveys(),
|
||||
fetchUsers().catch(() => [])
|
||||
])
|
||||
.then(([res, userRes]) => {
|
||||
if (!cancelled) {
|
||||
setData(res.data || res || []);
|
||||
setUsers(userRes || []);
|
||||
}
|
||||
})
|
||||
.catch(err => console.error("Error fetching data:", err))
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return loadData();
|
||||
}, []);
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await deleteCompetitorBranch(toDelete.id);
|
||||
setToDelete(null);
|
||||
loadData();
|
||||
} catch (e) {
|
||||
setDeleteError(e.message || 'Failed to delete. Please try again.');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filtered = data.filter(r =>
|
||||
(r.company || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.area || '').toLowerCase().includes(search.toLowerCase()) ||
|
||||
(r.phone || '').toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
|
||||
// Group filtered records by company
|
||||
const groupedData = filtered.reduce((acc, row) => {
|
||||
const comp = row.company || 'Unknown Company';
|
||||
if (!acc[comp]) acc[comp] = [];
|
||||
acc[comp].push(row);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
const companyKeys = Object.keys(groupedData).sort();
|
||||
const paginatedKeys = companyKeys.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage);
|
||||
|
||||
const stats = {
|
||||
total: data.length,
|
||||
quoted: data.filter(d => d.rate_per_kg && !String(d.rate_per_kg).toLowerCase().includes('not answered')).length,
|
||||
missing: data.filter(d => !d.rate_per_kg || String(d.rate_per_kg).toLowerCase().includes('not answered')).length,
|
||||
};
|
||||
|
||||
return (
|
||||
<Box sx={{ pb: 6, bgcolor: '#f8fafc', px: { xs: 2, md: 4 }, pt: 3, minHeight: '100vh' }}>
|
||||
<PageHeader
|
||||
title="Field Surveys"
|
||||
breadcrumbs={[{ label: 'Survey Management' }]}
|
||||
/>
|
||||
|
||||
<Grid container spacing={3} sx={{ mb: 5, mt: 1 }}>
|
||||
<Grid item xs={12} md={4}>
|
||||
<MagicStatCard
|
||||
title="TOTAL ENQUIRIES"
|
||||
value={loading ? '...' : stats.total}
|
||||
icon={AssignmentOutlinedIcon}
|
||||
gradient="linear-gradient(135deg, #e11d48 0%, #c01227 100%)"
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<MagicStatCard
|
||||
title="RATES QUOTED"
|
||||
value={loading ? '...' : stats.quoted}
|
||||
icon={StorefrontOutlinedIcon}
|
||||
gradient="linear-gradient(135deg, #10b981 0%, #047857 100%)"
|
||||
percentage={stats.total ? `${Math.round((stats.quoted / stats.total) * 100)}%` : '0%'}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs={12} md={4}>
|
||||
<MagicStatCard
|
||||
title="MISSING INFO"
|
||||
value={loading ? '...' : stats.missing}
|
||||
icon={PhoneOutlinedIcon}
|
||||
gradient="linear-gradient(135deg, #334155 0%, #0f172a 100%)"
|
||||
percentage={stats.total ? `${Math.round((stats.missing / stats.total) * 100)}%` : '0%'}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Card sx={{ borderRadius: 2, boxShadow: '0 4px 20px rgba(0,0,0,0.05)', overflow: 'hidden' }}>
|
||||
|
||||
{/* Header Section */}
|
||||
<Box sx={{ p: 3, display: 'flex', alignItems: 'center', gap: 2, borderBottom: '1px solid', borderColor: 'grey.100' }}>
|
||||
<Box sx={{
|
||||
width: 48, height: 48, borderRadius: 2.5,
|
||||
bgcolor: alpha('#c01227', 0.1), color: '#c01227',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center'
|
||||
}}>
|
||||
<AssignmentOutlinedIcon />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h6" sx={{ fontWeight: 700, color: 'grey.900' }}>
|
||||
Survey Records
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ color: 'grey.600', mt: 0.5 }}>
|
||||
Manage business enquiry and survey data from the field
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Toolbar Section */}
|
||||
<Box sx={{ p: 2, borderBottom: '1px solid', borderColor: 'grey.100', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<TextField
|
||||
size="small"
|
||||
placeholder="Search by company, area or phone..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ width: { xs: '100%', md: 400 } }}
|
||||
InputProps={{
|
||||
startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment>,
|
||||
}}
|
||||
/>
|
||||
<Stack direction="row" spacing={2} alignItems="center">
|
||||
{!loading && (
|
||||
<Typography variant="body2" sx={{ color: 'grey.500', fontWeight: 600 }}>
|
||||
{filtered.length} records
|
||||
</Typography>
|
||||
)}
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingRecord(null); setFormOpen(true); }} sx={{ bgcolor: '#c01227', '&:hover': { bgcolor: '#a00f20' }, borderRadius: 2, px: 2, fontWeight: 700, whiteSpace: 'nowrap' }}>
|
||||
Add New Survey
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* List Section */}
|
||||
<Box sx={{ p: 3, bgcolor: 'grey.50' }}>
|
||||
{loading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 8 }}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
) : paginatedKeys.length > 0 ? (
|
||||
<Box>
|
||||
{paginatedKeys.map((companyName) => (
|
||||
<CompanyGroup
|
||||
key={companyName}
|
||||
companyName={companyName}
|
||||
branches={groupedData[companyName]}
|
||||
onEdit={(row) => { setEditingRecord(row); setFormOpen(true); }}
|
||||
onDelete={(row) => setToDelete(row)}
|
||||
users={users}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box sx={{ py: 10, textAlign: 'center' }}>
|
||||
<AssignmentOutlinedIcon sx={{ fontSize: 48, color: 'grey.300', mb: 2 }} />
|
||||
<Typography variant="h6" color="text.secondary">No survey records found.</Typography>
|
||||
<Typography variant="body2" color="text.disabled" sx={{ mt: 1 }}>Try adjusting your search filters.</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Pagination Section */}
|
||||
<Box sx={{ borderTop: '1px solid', borderColor: 'grey.200', bgcolor: 'white' }}>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={companyKeys.length}
|
||||
page={page}
|
||||
onPageChange={(_, newPage) => setPage(newPage)}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={(e) => { setRowsPerPage(parseInt(e.target.value, 10)); setPage(0); }}
|
||||
rowsPerPageOptions={[5, 10, 25]}
|
||||
labelRowsPerPage="Companies per page:"
|
||||
/>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
<SurveyFormDialog
|
||||
open={formOpen}
|
||||
onClose={() => setFormOpen(false)}
|
||||
onSave={() => loadData()}
|
||||
initialData={editingRecord}
|
||||
/>
|
||||
|
||||
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => { setToDelete(null); setDeleteError(null); }}>
|
||||
<DialogTitle>Delete survey record?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently remove the record for <strong>{toDelete?.area || toDelete?.company}</strong>. This cannot be undone.
|
||||
</DialogContentText>
|
||||
{deleteError && <Alert severity="error" sx={{ mt: 2 }}>{deleteError}</Alert>}
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={() => { setToDelete(null); setDeleteError(null); }} disabled={deleting} color="inherit">Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user