Finalize CRM module: bookings, pricing, survey with full UI integration and validation

This commit is contained in:
2026-06-22 17:47:36 +05:30
parent 59fc91f034
commit 72a1eb0701
23 changed files with 2620 additions and 326 deletions

895
src/pages/survey/Survey.jsx Normal file
View 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>
);
}