Finalize CRM transformation, mobile responsiveness, and Qdrant integration
This commit is contained in:
@@ -1,160 +1,396 @@
|
||||
import { useState, useMemo, Fragment } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState, useMemo, useEffect, Fragment } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab,
|
||||
Card, Stack, Button, TextField, InputAdornment, Box, Tabs, Tab, Grid,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow, IconButton,
|
||||
Tooltip, TablePagination, Typography, Collapse, Grid
|
||||
TablePagination, Typography, Collapse, CircularProgress, Alert, Tooltip, Chip, Divider, Link,
|
||||
Dialog, DialogTitle, DialogContent, DialogContentText, DialogActions
|
||||
} from '@mui/material';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import RefreshIcon from '@mui/icons-material/Refresh';
|
||||
import EditOutlinedIcon from '@mui/icons-material/EditOutlined';
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp';
|
||||
import ApartmentOutlinedIcon from '@mui/icons-material/ApartmentOutlined';
|
||||
import FiberNewOutlinedIcon from '@mui/icons-material/FiberNewOutlined';
|
||||
import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
|
||||
import HandshakeOutlinedIcon from '@mui/icons-material/HandshakeOutlined';
|
||||
import PhoneOutlinedIcon from '@mui/icons-material/PhoneOutlined';
|
||||
import PlaceOutlinedIcon from '@mui/icons-material/PlaceOutlined';
|
||||
import LocalShippingOutlinedIcon from '@mui/icons-material/LocalShippingOutlined';
|
||||
import StorefrontOutlinedIcon from '@mui/icons-material/StorefrontOutlined';
|
||||
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
|
||||
import NotesOutlinedIcon from '@mui/icons-material/NotesOutlined';
|
||||
import ArrowRightAltIcon from '@mui/icons-material/ArrowRightAlt';
|
||||
import MapOutlinedIcon from '@mui/icons-material/MapOutlined';
|
||||
import BadgeOutlinedIcon from '@mui/icons-material/BadgeOutlined';
|
||||
|
||||
import PageHeader from '@/components/PageHeader';
|
||||
import StatCard from '@/components/StatCard';
|
||||
import StatusChip from '@/components/StatusChip';
|
||||
import EmptyState from '@/components/EmptyState';
|
||||
import UserAvatar from '@/components/UserAvatar';
|
||||
import TabLabelCount from '@/components/TabLabelCount';
|
||||
import { tenants, tenantPricing } from '@/data/mock';
|
||||
import { inr } from '@/utils/format';
|
||||
import { fetchPoints, deletePoint, COLLECTIONS } from '@/utils/qdrant';
|
||||
import ClientFormDialog from './ClientFormDialog';
|
||||
|
||||
const TABS = [
|
||||
{ key: 'active', label: 'Active' },
|
||||
{ key: 'pending', label: 'Pending' },
|
||||
{ key: 'inactive', label: 'Inactive' }
|
||||
];
|
||||
const generateLogicalId = (id) => {
|
||||
const str = String(id).replace(/[^a-zA-Z0-9]/g, '').toUpperCase();
|
||||
return 'CLI-' + str.substring(0, 6).padStart(6, '0');
|
||||
};
|
||||
|
||||
// Map a raw Qdrant point from doormile_clients to a flat client row.
|
||||
function toClient(point) {
|
||||
const p = point.payload || {};
|
||||
|
||||
// Qdrant point.id is usually a UUID.
|
||||
// We generate a clean CLI-XXXXXX format based on it.
|
||||
const logicalId = generateLogicalId(point.id);
|
||||
|
||||
return {
|
||||
id: point.id,
|
||||
logicalId,
|
||||
// Force override the ugly payload client_timestamp string with the clean ID
|
||||
clientId: logicalId,
|
||||
name: p.name || '—',
|
||||
phone: p.phone || '',
|
||||
city: p.city || '',
|
||||
businessState: p.businessState || '',
|
||||
businessType: p.businessType || '',
|
||||
status: p.status || 'unknown',
|
||||
parcelVolume: p.parcelVolume ?? 0,
|
||||
activeContracts: p.activeContracts ?? 0,
|
||||
frequency: p.frequency || '',
|
||||
provider: p.provider || '',
|
||||
efficiency: p.efficiency || '',
|
||||
logisticsSegment: p.logisticsSegment || '',
|
||||
transitFrom: p.transitFrom || '',
|
||||
transitTo: p.transitTo || '',
|
||||
neighbourhood: p.neighbourhood || p.surveyZone || '',
|
||||
surveyAddress: p.surveyAddress || '',
|
||||
surveyLat: p.surveyLat ?? p.surveyGeo?.lat ?? '',
|
||||
surveyLng: p.surveyLng ?? p.surveyGeo?.lon ?? '',
|
||||
dataConsent: p.dataConsent || '',
|
||||
lastUpdated: p.lastUpdated || '',
|
||||
notes: p.notes || ''
|
||||
};
|
||||
}
|
||||
|
||||
// Humanize raw payload tokens like `basicOnly`, `newClient`, `first_mile` → "Basic Only".
|
||||
const humanize = (s) =>
|
||||
String(s || '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/([a-z\d])([A-Z])/g, '$1 $2')
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
.trim();
|
||||
|
||||
const titleCase = humanize;
|
||||
|
||||
// Map categorical enum values to a semantic palette color.
|
||||
const consentTone = (v) => ({ full: 'success', basiconly: 'info', none: 'default' }[String(v || '').toLowerCase()] || 'default');
|
||||
const efficiencyTone = (v) => {
|
||||
const k = String(v || '').toLowerCase();
|
||||
if (/high|good|excellent/.test(k)) return 'success';
|
||||
if (/med|average|moderate/.test(k)) return 'warning';
|
||||
if (/low|poor|bad/.test(k)) return 'error';
|
||||
return 'info';
|
||||
};
|
||||
|
||||
const FIELD_LABEL_SX = { textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700, color: 'grey.700' };
|
||||
|
||||
// A soft, theme-tinted pill for categorical values. Falls back to a dash placeholder.
|
||||
function Pill({ label, color = 'default' }) {
|
||||
if (label === undefined || label === null || 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` })
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Label + arbitrary node value (text, pill, or grouped chips).
|
||||
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 ReadField({ label, value }) {
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{value || '—'}</Typography>
|
||||
</Box>
|
||||
<Field label={label}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: value ? 'grey.800' : 'grey.500', wordBreak: 'break-word' }}>
|
||||
{value || '—'}
|
||||
</Typography>
|
||||
</Field>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingTab() {
|
||||
// A bordered sub-card with a tinted header strip — frames each group of fields.
|
||||
function SectionCard({ icon: Icon, title, accent = 'primary', children }) {
|
||||
return (
|
||||
<Box>
|
||||
<Stack direction="row" justifyContent="flex-end" sx={{ mb: 1.5 }}>
|
||||
<Button variant="contained" startIcon={<AddIcon />}>Add Pricing</Button>
|
||||
<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: `${accent}.lighter`, opacity: 0.999 }}>
|
||||
<Icon sx={{ fontSize: 18, color: `${accent}.main` }} />
|
||||
<Typography variant="overline" sx={{ fontWeight: 700, color: 'grey.800', letterSpacing: 0.6, lineHeight: 1 }}>{title}</Typography>
|
||||
</Stack>
|
||||
<Box sx={{ border: 1, borderColor: 'divider', borderRadius: 1, overflow: 'hidden' }}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Date</TableCell>
|
||||
<TableCell>Slab</TableCell>
|
||||
<TableCell align="right">Base Price</TableCell>
|
||||
<TableCell align="right">Min Kms</TableCell>
|
||||
<TableCell align="right">Price/Km</TableCell>
|
||||
<TableCell align="right">Other Charges</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{tenantPricing.map((p, i) => (
|
||||
<TableRow key={i}>
|
||||
<TableCell>{p.date}</TableCell>
|
||||
<TableCell>{p.slab}</TableCell>
|
||||
<TableCell align="right">{inr(p.basePrice)}</TableCell>
|
||||
<TableCell align="right">{p.minKms}</TableCell>
|
||||
<TableCell align="right">{inr(p.pricePerKm)}</TableCell>
|
||||
<TableCell align="right">{inr(p.otherCharges)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Box>
|
||||
<Stack spacing={1.75} sx={{ p: 2 }}>{children}</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function EditTab({ tenant }) {
|
||||
const [form, setForm] = useState({
|
||||
name: tenant.name, contact: tenant.contact, phone: tenant.phone, email: tenant.email,
|
||||
address: tenant.address, city: tenant.city, postcode: tenant.postcode, lat: tenant.lat, lng: tenant.lng
|
||||
});
|
||||
const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
|
||||
|
||||
// Mini KPI tile used inside the Business card for the headline numbers.
|
||||
function StatTile({ label, value, icon: Icon, color = 'primary' }) {
|
||||
return (
|
||||
<Box>
|
||||
<Grid container spacing={2}>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Tenant Name" value={form.name} onChange={set('name')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Contact Person" value={form.contact} onChange={set('contact')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Contact Number" value={form.phone} onChange={set('phone')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Email" value={form.email} onChange={set('email')} /></Grid>
|
||||
<Grid item xs={12}><TextField fullWidth size="small" label="Address" value={form.address} onChange={set('address')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="City" value={form.city} onChange={set('city')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="PostCode" value={form.postcode} onChange={set('postcode')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Latitude" value={form.lat} onChange={set('lat')} /></Grid>
|
||||
<Grid item xs={12} sm={6}><TextField fullWidth size="small" label="Longitude" value={form.lng} onChange={set('lng')} /></Grid>
|
||||
</Grid>
|
||||
<Stack direction="row" justifyContent="flex-end" sx={{ mt: 2.5 }}>
|
||||
<Button variant="contained">Update</Button>
|
||||
<Box sx={{ flex: 1, p: 1.5, borderRadius: 1.5, border: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Box sx={{ width: 32, height: 32, borderRadius: 1, bgcolor: `${color}.lighter`, color: `${color}.main`, display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Icon sx={{ fontSize: 18 }} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.1 }}>{value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{label}</Typography>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function TenantRow({ row, index }) {
|
||||
// Compact metric used inline in the table so key numbers show without expanding.
|
||||
function Metric({ label, value, color = 'grey.800' }) {
|
||||
return (
|
||||
<Box sx={{ textAlign: 'center', minWidth: 56 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700, color, lineHeight: 1.2 }}>{value}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ fontSize: '0.65rem' }}>{label}</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientRow({ row, index, onEdit, onDelete }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [inner, setInner] = useState(0);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<TableRow hover sx={{ '& > *': { borderBottom: open ? 'unset' : undefined } }}>
|
||||
<TableRow
|
||||
hover
|
||||
sx={{
|
||||
cursor: 'pointer',
|
||||
'& > *': { borderBottom: open ? 'unset' : undefined },
|
||||
...(open && { bgcolor: 'primary.lighter', '&:hover': { bgcolor: 'primary.lighter' } })
|
||||
}}
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
>
|
||||
<TableCell padding="checkbox">
|
||||
<IconButton size="small" onClick={() => setOpen((o) => !o)}>
|
||||
<IconButton size="small" onClick={(e) => { e.stopPropagation(); setOpen((o) => !o); }}>
|
||||
{open ? <KeyboardArrowUpIcon /> : <KeyboardArrowDownIcon />}
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
<TableCell>{index + 1}</TableCell>
|
||||
<TableCell sx={{ whiteSpace: 'nowrap' }}>
|
||||
<Typography variant="caption" sx={{ fontFamily: 'monospace', fontWeight: 700, color: 'primary.main', bgcolor: 'primary.lighter', px: 1, py: 0.5, borderRadius: 1, border: '1px solid', borderColor: 'primary.light', whiteSpace: 'nowrap' }}>
|
||||
{row.logicalId}
|
||||
</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.25} alignItems="center">
|
||||
<UserAvatar name={row.name} size={34} />
|
||||
<Box>
|
||||
<UserAvatar name={row.name} size={38} />
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600, color: 'grey.800' }}>{row.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.volume} orders / mo</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.4 }}>
|
||||
{row.businessType && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={titleCase(row.businessType)}
|
||||
sx={{ height: 20, fontSize: '0.68rem', fontWeight: 600, bgcolor: 'grey.100', color: 'grey.700' }}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{row.contact}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.phone}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<PhoneOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />
|
||||
<Box>
|
||||
<Typography variant="body2">{row.phone || '—'}</Typography>
|
||||
{row.frequency && <Typography variant="caption" color="text.secondary">{row.frequency}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{row.address}</Typography>
|
||||
<Typography variant="caption" color="text.secondary">{row.city} · {row.postcode}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center">
|
||||
<PlaceOutlinedIcon sx={{ fontSize: 15, color: 'grey.400' }} />
|
||||
<Box>
|
||||
<Typography variant="body2">{row.city || '—'}{row.businessState ? `, ${row.businessState}` : ''}</Typography>
|
||||
{row.neighbourhood && <Typography variant="caption" color="text.secondary">{row.neighbourhood}</Typography>}
|
||||
</Box>
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center" justifyContent="center" divider={<Divider orientation="vertical" flexItem />}>
|
||||
<Metric label="Parcels" value={Number(row.parcelVolume).toLocaleString()} />
|
||||
<Metric label="Contracts" value={row.activeContracts} color="primary.main" />
|
||||
</Stack>
|
||||
</TableCell>
|
||||
<TableCell><StatusChip status={row.status} /></TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onEdit(row); }}><EditOutlinedIcon fontSize="small" /></IconButton></Tooltip>
|
||||
<Tooltip title="Delete"><IconButton size="small" onClick={(e) => { e.stopPropagation(); onDelete(row); }}><DeleteOutlineIcon fontSize="small" /></IconButton></Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}>
|
||||
<TableCell colSpan={8} sx={{ py: 0, borderBottom: open ? undefined : 'none' }}>
|
||||
<Collapse in={open} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ m: 2, borderRadius: 1, border: 1, borderColor: 'divider', overflow: 'hidden' }}>
|
||||
<Box sx={{ borderBottom: 1, borderColor: 'divider', bgcolor: 'grey.50' }}>
|
||||
<Tabs value={inner} onChange={(_, v) => setInner(v)} sx={{ px: 2 }}>
|
||||
<Tab label="Details" />
|
||||
<Tab label="Pricing" />
|
||||
<Tab label="Edit" />
|
||||
</Tabs>
|
||||
</Box>
|
||||
<Box sx={{ p: 2.5 }}>
|
||||
{inner === 0 && (
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Name" value={row.name} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Contact Person" value={row.contact} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Phone" value={row.phone} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="E-Mail" value={row.email} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Address" value={row.address} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="City" value={row.city} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="PostCode" value={row.postcode} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Latitude" value={row.lat} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Longitude" value={row.lng} /></Grid>
|
||||
<Box sx={{ m: 2, borderRadius: 2.5, border: 1, borderColor: 'divider', overflow: 'hidden', boxShadow: '0 4px 16px rgba(0,0,0,0.06)' }}>
|
||||
<Stack
|
||||
direction={{ xs: 'column', sm: 'row' }}
|
||||
justifyContent="space-between"
|
||||
alignItems={{ xs: 'flex-start', sm: 'center' }}
|
||||
spacing={1.5}
|
||||
sx={{
|
||||
px: 2.5, py: 1.75, borderBottom: 1, borderColor: 'divider',
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}88 0%, ${theme.palette.background.paper} 75%)`
|
||||
}}
|
||||
>
|
||||
<Stack direction="row" spacing={1.5} alignItems="center">
|
||||
<UserAvatar name={row.name} size={40} />
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, lineHeight: 1.2 }}>{row.name}</Typography>
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" sx={{ mt: 0.25 }}>
|
||||
<BadgeOutlinedIcon sx={{ fontSize: 14, color: 'grey.400' }} />
|
||||
<Typography variant="caption" color="text.secondary">{row.clientId}</Typography>
|
||||
</Stack>
|
||||
</Box>
|
||||
<StatusChip status={row.status} sx={{ ml: 0.5 }} />
|
||||
</Stack>
|
||||
<Button size="small" variant="contained" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(row)}>Edit Client</Button>
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ p: 2.5, bgcolor: 'grey.50' }}>
|
||||
<Grid container spacing={2.5} alignItems="stretch">
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={StorefrontOutlinedIcon} title="Business" accent="primary">
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<StatTile label="Parcel Volume" value={Number(row.parcelVolume).toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" />
|
||||
<StatTile label="Active Contracts" value={row.activeContracts} icon={HandshakeOutlinedIcon} color="primary" />
|
||||
</Stack>
|
||||
<Field label="Client ID">
|
||||
<Typography variant="body2" sx={{ fontFamily: 'monospace', fontWeight: 600, color: 'grey.800' }}>{row.clientId}</Typography>
|
||||
</Field>
|
||||
<Field label="Business Type"><Pill label={row.businessType && titleCase(row.businessType)} color="primary" /></Field>
|
||||
<Field label="Order Frequency"><Pill label={row.frequency && titleCase(row.frequency)} color="info" /></Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
)}
|
||||
{inner === 1 && <PricingTab />}
|
||||
{inner === 2 && <EditTab tenant={row} />}
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={LocalShippingOutlinedIcon} title="Logistics" accent="primary">
|
||||
<Field label="Logistics Segment">
|
||||
{row.logisticsSegment ? (
|
||||
<Stack direction="row" spacing={0.75} flexWrap="wrap" useFlexGap>
|
||||
{String(row.logisticsSegment).split(/[,/]/).map((seg) => seg.trim()).filter(Boolean).map((seg) => (
|
||||
<Pill key={seg} label={titleCase(seg)} color="primary" />
|
||||
))}
|
||||
</Stack>
|
||||
) : <Pill label={null} />}
|
||||
</Field>
|
||||
<ReadField label="Current Provider" value={row.provider} />
|
||||
<Field label="Efficiency"><Pill label={row.efficiency && titleCase(row.efficiency)} color={efficiencyTone(row.efficiency)} /></Field>
|
||||
<Field label="Transit Route">
|
||||
{(() => {
|
||||
const stops = [row.transitFrom, ...String(row.transitTo || '').split(',')]
|
||||
.map((s) => s.trim()).filter(Boolean);
|
||||
if (!stops.length) return <Pill label={null} />;
|
||||
return (
|
||||
<Stack direction="row" spacing={0.75} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
{stops.map((stop, idx) => (
|
||||
<Fragment key={`${stop}-${idx}`}>
|
||||
{idx > 0 && <ArrowRightAltIcon sx={{ fontSize: 20, color: 'primary.main' }} />}
|
||||
<Pill label={titleCase(stop)} color={idx === 0 ? 'default' : 'info'} />
|
||||
</Fragment>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
})()}
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12} md={4}>
|
||||
<SectionCard icon={PlaceOutlinedIcon} title="Location & Survey" accent="primary">
|
||||
<Field label="City / State">
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800' }}>{[row.city, row.businessState].filter(Boolean).join(', ') || '—'}</Typography>
|
||||
</Field>
|
||||
<ReadField label="Neighbourhood" value={row.neighbourhood} />
|
||||
<Field label="Survey Address">
|
||||
<Stack direction="row" spacing={1} sx={{ p: 1.25, 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.surveyAddress || '—'}</Typography>
|
||||
</Stack>
|
||||
</Field>
|
||||
<Field label="Coordinates">
|
||||
{row.surveyLat && row.surveyLng ? (
|
||||
<Stack direction="row" spacing={1.25} alignItems="center" flexWrap="wrap" useFlexGap>
|
||||
<Tooltip title={`${row.surveyLat}, ${row.surveyLng}`} arrow placement="top">
|
||||
<Stack direction="row" spacing={0.5} alignItems="center" sx={{ cursor: 'default' }}>
|
||||
<PlaceOutlinedIcon sx={{ fontSize: 16, color: 'grey.400' }} />
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, color: 'grey.800', borderBottom: '1px dotted', borderColor: 'grey.300' }}>
|
||||
{[row.neighbourhood, row.city].filter(Boolean).join(', ') || 'Pinned location'}
|
||||
</Typography>
|
||||
</Stack>
|
||||
</Tooltip>
|
||||
<Button
|
||||
component="a"
|
||||
href={`https://www.google.com/maps?q=${row.surveyLat},${row.surveyLng}`}
|
||||
target="_blank" rel="noopener"
|
||||
size="small" variant="outlined" startIcon={<MapOutlinedIcon sx={{ fontSize: 16 }} />}
|
||||
sx={{
|
||||
py: 0.25, px: 1.25, minWidth: 0, fontSize: '0.75rem', fontWeight: 600, borderRadius: 5,
|
||||
color: 'primary.main', borderColor: 'primary.100', bgcolor: 'primary.lighter',
|
||||
'&:hover': { borderColor: 'primary.main', bgcolor: 'primary.lighter' }
|
||||
}}
|
||||
>
|
||||
View on map
|
||||
</Button>
|
||||
</Stack>
|
||||
) : <Typography variant="body2" sx={{ color: 'grey.500' }}>—</Typography>}
|
||||
</Field>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
|
||||
<Grid item xs={12}>
|
||||
<SectionCard icon={InfoOutlinedIcon} title="Contact & Compliance" accent="primary">
|
||||
<Grid container spacing={2.5}>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Phone" value={row.phone} /></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><Field label="Data Consent"><Pill label={row.dataConsent && titleCase(row.dataConsent)} color={consentTone(row.dataConsent)} /></Field></Grid>
|
||||
<Grid item xs={12} sm={6} md={4}><ReadField label="Last Updated" value={row.lastUpdated} /></Grid>
|
||||
{row.notes && (
|
||||
<Grid item xs={12}>
|
||||
<Box sx={{ p: 2, borderRadius: 1.5, bgcolor: 'warning.lighter', border: 1, borderColor: 'warning.light' }}>
|
||||
<Stack direction="row" spacing={1} alignItems="center" sx={{ mb: 0.5 }}>
|
||||
<NotesOutlinedIcon sx={{ fontSize: 16, color: 'warning.dark' }} />
|
||||
<Typography variant="caption" sx={{ color: 'warning.dark', textTransform: 'uppercase', letterSpacing: 0.4, fontSize: '0.68rem', fontWeight: 700 }}>Notes</Typography>
|
||||
</Stack>
|
||||
<Typography variant="body2" sx={{ color: 'grey.800' }}>{row.notes}</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
)}
|
||||
</Grid>
|
||||
</SectionCard>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Box>
|
||||
</Collapse>
|
||||
@@ -165,85 +401,189 @@ function TenantRow({ row, index }) {
|
||||
}
|
||||
|
||||
export default function Tenants() {
|
||||
const navigate = useNavigate();
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const [searchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState(0);
|
||||
const [search, setSearch] = useState('');
|
||||
const [search, setSearch] = useState(searchParams.get('q') || '');
|
||||
const [page, setPage] = useState(0);
|
||||
const [rpp, setRpp] = useState(10);
|
||||
|
||||
const tabKey = TABS[tab].key;
|
||||
// Keep the search box in sync when navigated here with a ?q= query (e.g. from the top search bar).
|
||||
useEffect(() => {
|
||||
const q = searchParams.get('q');
|
||||
if (q != null) { setSearch(q); setPage(0); }
|
||||
}, [searchParams]);
|
||||
|
||||
const [dialog, setDialog] = useState({ open: false, mode: 'add', initial: null });
|
||||
const [toDelete, setToDelete] = useState(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
fetchPoints(COLLECTIONS.clients)
|
||||
.then((points) => setClients(points.map(toClient)))
|
||||
.catch((e) => setError(e.message || 'Failed to load clients'))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { load(); }, []);
|
||||
|
||||
const stats = useMemo(() => ({
|
||||
total: clients.length,
|
||||
newCount: clients.filter((c) => c.status === 'newClient').length,
|
||||
parcels: clients.reduce((s, c) => s + (Number(c.parcelVolume) || 0), 0),
|
||||
contracts: clients.reduce((s, c) => s + (Number(c.activeContracts) || 0), 0)
|
||||
}), [clients]);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const seen = [];
|
||||
clients.forEach((c) => { if (!seen.includes(c.status)) seen.push(c.status); });
|
||||
return [{ key: 'all', label: 'All' }, ...seen.map((s) => ({ key: s, label: titleCase(s) }))];
|
||||
}, [clients]);
|
||||
|
||||
const tabKey = tabs[Math.min(tab, tabs.length - 1)]?.key || 'all';
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = {};
|
||||
TABS.forEach((t) => { c[t.key] = tenants.filter((d) => d.status === t.key).length; });
|
||||
const c = { all: clients.length };
|
||||
clients.forEach((cl) => { c[cl.status] = (c[cl.status] || 0) + 1; });
|
||||
return c;
|
||||
}, []);
|
||||
}, [clients]);
|
||||
|
||||
const filtered = useMemo(
|
||||
() =>
|
||||
tenants.filter((t) => {
|
||||
const matchTab = t.status === tabKey;
|
||||
clients.filter((t) => {
|
||||
const matchTab = tabKey === 'all' || t.status === tabKey;
|
||||
const matchSearch =
|
||||
!search ||
|
||||
[t.name, t.contact, t.email, t.phone, t.city].join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
[t.name, t.phone, t.city, t.businessType, t.clientId, t.neighbourhood]
|
||||
.join(' ').toLowerCase().includes(search.toLowerCase());
|
||||
return matchTab && matchSearch;
|
||||
}),
|
||||
[tabKey, search]
|
||||
[clients, tabKey, search]
|
||||
);
|
||||
|
||||
const paged = filtered.slice(page * rpp, page * rpp + rpp);
|
||||
|
||||
const handleSaved = () => { setDialog({ open: false, mode: 'add', initial: null }); load(); };
|
||||
|
||||
const confirmDelete = async () => {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await deletePoint(COLLECTIONS.clients, toDelete.id);
|
||||
setToDelete(null);
|
||||
load();
|
||||
} catch (e) {
|
||||
setError(e.message || 'Failed to delete client');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="Tenants"
|
||||
breadcrumbs={[{ label: 'Tenants' }]}
|
||||
title="Clients"
|
||||
breadcrumbs={[{ label: 'Clients' }]}
|
||||
action={
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/tenants/create')}>
|
||||
Create Client
|
||||
</Button>
|
||||
<Stack direction="row" spacing={1.5}>
|
||||
<Button variant="outlined" startIcon={<RefreshIcon />} onClick={load} disabled={loading}>Refresh</Button>
|
||||
<Button variant="contained" startIcon={<AddIcon />} onClick={() => setDialog({ open: true, mode: 'add', initial: null })}>Add Client</Button>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Grid container spacing={2.5} sx={{ mb: 3 }}>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Clients" value={stats.total} icon={ApartmentOutlinedIcon} caption="All registered" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="New Clients" value={stats.newCount} icon={FiberNewOutlinedIcon} color="primary" caption="Awaiting onboarding" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Total Parcel Volume" value={stats.parcels.toLocaleString()} icon={Inventory2OutlinedIcon} color="primary" caption="Across all clients" /></Grid>
|
||||
<Grid item xs={12} sm={6} lg={3}><StatCard accent title="Active Contracts" value={stats.contracts} icon={HandshakeOutlinedIcon} color="primary" caption="Currently running" /></Grid>
|
||||
</Grid>
|
||||
|
||||
<Card sx={{ overflow: 'hidden' }}>
|
||||
<Box
|
||||
sx={{
|
||||
px: 2.5, py: 2, borderBottom: 1, borderColor: 'divider',
|
||||
display: 'flex', alignItems: 'center', gap: 1.5,
|
||||
background: (theme) => `linear-gradient(90deg, ${theme.palette.primary.lighter}66 0%, ${theme.palette.background.paper} 70%)`
|
||||
}}
|
||||
>
|
||||
<Box sx={{ width: 40, height: 40, borderRadius: 2, bgcolor: 'primary.lighter', color: 'primary.main', display: 'inline-flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<ApartmentOutlinedIcon fontSize="small" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: 'grey.800', lineHeight: 1.2 }}>Client Directory</Typography>
|
||||
<Typography variant="caption" color="text.secondary">Browse, search and manage every client account</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Stack direction={{ xs: 'column', md: 'row' }} spacing={1.5} sx={{ p: 2 }} alignItems={{ md: 'center' }}>
|
||||
<TextField
|
||||
size="small" placeholder="Search clients…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 260 }}
|
||||
size="small" placeholder="Search by name, phone, city, ID…" value={search} onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
sx={{ minWidth: 300 }}
|
||||
InputProps={{ startAdornment: <InputAdornment position="start"><SearchIcon fontSize="small" /></InputAdornment> }}
|
||||
/>
|
||||
<Box sx={{ flexGrow: 1 }} />
|
||||
{!loading && (
|
||||
<Stack direction="row" spacing={1} alignItems="center">
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{filtered.length} {filtered.length === 1 ? 'client' : 'clients'}
|
||||
</Typography>
|
||||
<Chip size="small" label="live · doormile_clients" sx={{ height: 22, fontSize: '0.7rem', bgcolor: 'success.lighter', color: 'success.dark', fontWeight: 600 }} />
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box sx={{ px: 2, borderBottom: 1, borderColor: 'divider' }}>
|
||||
<Tabs value={tab} onChange={(_, v) => { setTab(v); setPage(0); }}>
|
||||
{TABS.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key]} active={tab === i} />} />
|
||||
<Tabs value={Math.min(tab, tabs.length - 1)} onChange={(_, v) => { setTab(v); setPage(0); }} variant="scrollable" scrollButtons="auto">
|
||||
{tabs.map((t, i) => (
|
||||
<Tab key={t.key} label={<TabLabelCount label={t.label} count={counts[t.key] || 0} active={tab === i} />} />
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{error && <Alert severity="error" sx={{ m: 2 }} action={<Button color="inherit" size="small" onClick={load}>Retry</Button>}>{error}</Alert>}
|
||||
|
||||
<TableContainer>
|
||||
<Table>
|
||||
<Table sx={{ minWidth: 900 }}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableRow sx={{ '& th': { bgcolor: 'grey.50', fontWeight: 700, color: 'grey.700', textTransform: 'uppercase', fontSize: '0.72rem', letterSpacing: 0.4 } }}>
|
||||
<TableCell padding="checkbox" />
|
||||
<TableCell>S.No</TableCell>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>Client</TableCell>
|
||||
<TableCell>Contact</TableCell>
|
||||
<TableCell>Address</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
<TableCell>Location</TableCell>
|
||||
<TableCell align="center">Volume</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{paged.length === 0 ? (
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} sx={{ border: 'none' }}>
|
||||
<EmptyState title="No tenants found" caption="Try a different tab or search term." />
|
||||
<TableCell colSpan={8} sx={{ border: 'none' }}>
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 6 }}><CircularProgress /></Box>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : paged.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={8} sx={{ border: 'none' }}>
|
||||
<EmptyState title="No clients found" caption="Try a different tab or search term, or add a client." />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
paged.map((row, i) => <TenantRow key={row.id} row={row} index={page * rpp + i} />)
|
||||
paged.map((row, i) => (
|
||||
<ClientRow
|
||||
key={row.id}
|
||||
row={row}
|
||||
index={page * rpp + i}
|
||||
onEdit={(r) => setDialog({ open: true, mode: 'edit', initial: r })}
|
||||
onDelete={(r) => setToDelete(r)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -253,6 +593,27 @@ export default function Tenants() {
|
||||
rowsPerPage={rpp} onRowsPerPageChange={(e) => { setRpp(+e.target.value); setPage(0); }} rowsPerPageOptions={[5, 10, 25]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<ClientFormDialog
|
||||
open={dialog.open}
|
||||
mode={dialog.mode}
|
||||
initial={dialog.initial}
|
||||
onClose={() => setDialog({ open: false, mode: 'add', initial: null })}
|
||||
onSaved={handleSaved}
|
||||
/>
|
||||
|
||||
<Dialog open={!!toDelete} onClose={deleting ? undefined : () => setToDelete(null)}>
|
||||
<DialogTitle>Delete client?</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>
|
||||
This will permanently remove <strong>{toDelete?.name}</strong> from the doormile_clients collection. This cannot be undone.
|
||||
</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={() => setToDelete(null)} disabled={deleting}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={confirmDelete} disabled={deleting} startIcon={deleting ? <CircularProgress size={16} color="inherit" /> : null}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user