603 lines
22 KiB
JavaScript
603 lines
22 KiB
JavaScript
import React, { useMemo, useRef, useState } from 'react';
|
|
import {
|
|
Avatar,
|
|
Box,
|
|
Grid,
|
|
Paper,
|
|
Stack,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
Typography,
|
|
useMediaQuery,
|
|
useTheme
|
|
} from '@mui/material';
|
|
import {
|
|
MdLocalOffer,
|
|
MdMyLocation,
|
|
MdAttachMoney,
|
|
MdGroups,
|
|
MdPlace,
|
|
MdSpeed,
|
|
MdPriceCheck,
|
|
MdStraighten,
|
|
MdReceiptLong,
|
|
MdOutlineLocalOffer,
|
|
MdOutlineGroups,
|
|
MdOutlineAttachMoney,
|
|
MdOutlinePlace
|
|
} from 'react-icons/md';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
import Loader from 'components/Loader';
|
|
import DebounceSearchBar from 'components/nearle_components/DebounceSearchBar';
|
|
import LocationAutocomplete from 'components/nearle_components/LocationAutocomplete';
|
|
import PageHeader from 'components/nearle_components/PageHeader';
|
|
import StatCard from 'components/nearle_components/StatCard';
|
|
import { MobileCard, MobileCardList, MobileField, MobileFieldGrid } from 'components/nearle_components/MobileCard';
|
|
import { OrdersTableSkeleton } from '../orders/OrdersTableSkeleton';
|
|
import { getallpricing } from 'pages/api/api';
|
|
|
|
// ============================================================================
|
|
// Design tokens — shared with the deliveries / tenants / customers pages so every
|
|
// surface (header, KPI tiles, table, badges) speaks the same visual language.
|
|
// Keep this block in sync with customers.js / deliveries.js.
|
|
// ============================================================================
|
|
const DT = {
|
|
radiusPill: 999,
|
|
radiusCard: 14,
|
|
radiusField: 10,
|
|
shadowSoft: '0 1px 2px rgba(15, 23, 42, 0.04), 0 8px 24px rgba(15, 23, 42, 0.05)',
|
|
shadowMd: '0 1px 3px rgba(15, 23, 42, 0.06)',
|
|
shadowPop: '0 12px 32px rgba(15, 23, 42, 0.12)',
|
|
textPrimary: '#0f172a',
|
|
textSecondary: '#64748b',
|
|
textMuted: '#94a3b8',
|
|
borderSubtle: '#e2e8f0',
|
|
borderHover: '#cbd5e1',
|
|
divider: '#f1f5f9',
|
|
surface: '#ffffff',
|
|
surfaceAlt: '#f8fafc',
|
|
brand: '#C01227'
|
|
};
|
|
const a = (c, suffix) => `${c}${suffix}`;
|
|
const tint = (c) => a(c, '08');
|
|
const soft = (c) => a(c, '18');
|
|
const ring = (c) => a(c, '26');
|
|
const edge = (c) => a(c, '55');
|
|
|
|
const BRAND = '#C01227';
|
|
|
|
const SoftPaper = (props) => (
|
|
<Paper
|
|
{...props}
|
|
sx={{
|
|
mt: 0.75,
|
|
borderRadius: 2,
|
|
boxShadow: DT.shadowPop,
|
|
border: '1px solid',
|
|
borderColor: 'divider',
|
|
overflow: 'hidden'
|
|
}}
|
|
/>
|
|
);
|
|
|
|
const AccentAvatar = ({ color, selected, size = 24, children }) => (
|
|
<Avatar
|
|
sx={{
|
|
width: size,
|
|
height: size,
|
|
bgcolor: selected ? color : soft(color),
|
|
color: selected ? '#fff' : color,
|
|
transition: 'background-color 0.15s, color 0.15s'
|
|
}}
|
|
>
|
|
{children}
|
|
</Avatar>
|
|
);
|
|
|
|
const formatRupees = (value) =>
|
|
new Intl.NumberFormat('en-IN', {
|
|
style: 'currency',
|
|
currency: 'INR',
|
|
minimumFractionDigits: 2
|
|
}).format(Number(value) || 0);
|
|
|
|
const formatDecimal = (value) =>
|
|
new Intl.NumberFormat('en-IN', { minimumFractionDigits: 2 }).format(Number(value) || 0);
|
|
|
|
// Numeric table value — plain, strong, right-readable text. The old version
|
|
// wrapped every cell in a coloured bordered pill, which made the table read
|
|
// like a rainbow; corporate data tables keep figures as quiet typography and
|
|
// let the column header carry the meaning.
|
|
const MetricPill = ({ label }) => (
|
|
<Typography
|
|
component="span"
|
|
sx={{ fontSize: 13.5, fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums' }}
|
|
>
|
|
{label}
|
|
</Typography>
|
|
);
|
|
|
|
// Subtle neutral category chip (zone / slab) — one quiet style, muted icon.
|
|
const CategoryChip = ({ icon, label }) => (
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
px: 1,
|
|
py: 0.375,
|
|
borderRadius: 8,
|
|
bgcolor: DT.surfaceAlt,
|
|
border: `1px solid ${DT.borderSubtle}`,
|
|
color: DT.textPrimary,
|
|
fontSize: 12,
|
|
fontWeight: 600,
|
|
whiteSpace: 'nowrap'
|
|
}}
|
|
>
|
|
<Box component="span" sx={{ display: 'inline-flex', color: DT.textMuted }}>
|
|
{icon}
|
|
</Box>
|
|
{label}
|
|
</Box>
|
|
);
|
|
|
|
// ==============================|| Pricing page ||============================== //
|
|
|
|
const ClientsPricing = () => {
|
|
const theme = useTheme();
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
const containerRef = useRef();
|
|
const [appId, setAppId] = useState(0);
|
|
const [locaName, setLocoName] = useState('All');
|
|
const [searchword, setSearchword] = useState('');
|
|
const [debouncedSearch, setDebouncedSearch] = useState('');
|
|
|
|
const {
|
|
data: pricing = [],
|
|
isLoading
|
|
} = useQuery({
|
|
queryKey: ['getallpricing', appId],
|
|
queryFn: getallpricing,
|
|
keepPreviousData: true
|
|
});
|
|
|
|
const rows = useMemo(() => {
|
|
if (!debouncedSearch) return pricing;
|
|
const q = debouncedSearch.toLowerCase().trim();
|
|
return pricing.filter((row) =>
|
|
[row.applocation, row.appname, row.slab, String(row.pricingid)]
|
|
.filter(Boolean)
|
|
.some((field) => String(field).toLowerCase().includes(q))
|
|
);
|
|
}, [pricing, debouncedSearch]);
|
|
|
|
const stats = useMemo(() => {
|
|
const total = pricing.length;
|
|
const tenants = new Set(pricing.map((r) => r.appname).filter(Boolean)).size;
|
|
const avgBase = total
|
|
? pricing.reduce((sum, r) => sum + (Number(r.baseprice) || 0), 0) / total
|
|
: 0;
|
|
return { total, tenants, avgBase };
|
|
}, [pricing]);
|
|
|
|
const KPI_META = [
|
|
{ key: 'total', label: 'Total Pricing Slabs', color: BRAND, icon: MdOutlineLocalOffer, value: stats.total },
|
|
{ key: 'tenants', label: 'Tenants Priced', color: '#0ea5e9', icon: MdOutlineGroups, value: stats.tenants },
|
|
{ key: 'avg', label: 'Avg Base Price', color: '#f59e0b', icon: MdOutlineAttachMoney, value: formatRupees(stats.avgBase) },
|
|
{ key: 'zone', label: 'Active Zone', color: '#10b981', icon: MdOutlinePlace, value: locaName || 'All Zones' }
|
|
];
|
|
|
|
return (
|
|
<>
|
|
{isLoading && <Loader />}
|
|
|
|
{/* ============================================= || Header || ============================================= */}
|
|
<PageHeader
|
|
title="Pricing"
|
|
subtitle={`Live · ${locaName || 'All Zones'}`}
|
|
live
|
|
action={
|
|
<LocationAutocomplete
|
|
locaName={locaName}
|
|
setAppId={setAppId}
|
|
setLocoName={setLocoName}
|
|
pill
|
|
accentColor={BRAND}
|
|
icon={<MdMyLocation size={14} />}
|
|
placeholder="Select Zone"
|
|
paperComponent={SoftPaper}
|
|
sx={{ width: { xs: '100%', sm: 280 }, zIndex: 100 }}
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{/* ============================================= || KPI Cards || ============================================= */}
|
|
<Grid container spacing={{ xs: 2, md: 2.5 }} sx={{ mb: { xs: 1.5, md: 2 } }}>
|
|
{KPI_META.map((item) => {
|
|
const Icon = item.icon;
|
|
return (
|
|
<Grid item key={item.key} xs={6} sm={6} md={3}>
|
|
<StatCard title={item.label} value={item.value} icon={<Icon size={20} />} color={item.color} />
|
|
</Grid>
|
|
);
|
|
})}
|
|
</Grid>
|
|
|
|
{/* ============================================= || Search Header || ============================================= */}
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
mt: { xs: 1.5, md: 2 },
|
|
p: { xs: 1, md: 1.5 },
|
|
borderTopLeftRadius: DT.radiusCard / 8,
|
|
borderTopRightRadius: DT.radiusCard / 8,
|
|
borderBottomLeftRadius: 0,
|
|
borderBottomRightRadius: 0,
|
|
border: '1px solid',
|
|
borderColor: DT.borderSubtle,
|
|
borderBottom: 0,
|
|
background: '#fff'
|
|
}}
|
|
>
|
|
<Stack
|
|
direction={{ xs: 'column', sm: 'row' }}
|
|
alignItems={{ xs: 'stretch', sm: 'center' }}
|
|
justifyContent="space-between"
|
|
spacing={1.25}
|
|
>
|
|
<Stack direction="row" alignItems="center" spacing={1.25}>
|
|
<AccentAvatar color={BRAND} size={32}>
|
|
<MdLocalOffer size={18} />
|
|
</AccentAvatar>
|
|
<Stack>
|
|
<Typography
|
|
variant="caption"
|
|
sx={{ fontWeight: 800, color: DT.textSecondary, letterSpacing: 0.6, textTransform: 'uppercase' }}
|
|
>
|
|
Pricing Catalog
|
|
</Typography>
|
|
<Typography variant="body2" sx={{ color: DT.textPrimary, fontWeight: 700 }}>
|
|
{pricing.length} total · {rows.length} shown
|
|
</Typography>
|
|
</Stack>
|
|
</Stack>
|
|
<Box sx={{ width: { xs: '100%', sm: 280, lg: 340 }, flex: { xs: '1 1 100%', sm: '0 0 auto' } }}>
|
|
<DebounceSearchBar
|
|
value={searchword}
|
|
onChange={setSearchword}
|
|
onDebouncedChange={setDebouncedSearch}
|
|
placeholder={`Search pricing (ctrl+k)`}
|
|
sx={{
|
|
m: 0,
|
|
width: '100%',
|
|
borderRadius: DT.radiusField + 'px',
|
|
bgcolor: DT.surface,
|
|
'& fieldset': { borderColor: DT.borderSubtle, borderWidth: 1 },
|
|
'&:hover fieldset': { borderColor: DT.borderHover },
|
|
'&.Mui-focused fieldset': { borderColor: BRAND, borderWidth: 1.5 },
|
|
'&.Mui-focused': { boxShadow: `0 0 0 3px ${ring(BRAND)}` }
|
|
}}
|
|
/>
|
|
</Box>
|
|
</Stack>
|
|
</Paper>
|
|
|
|
{/* ============================================= || Table || ============================================= */}
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
borderTopLeftRadius: 0,
|
|
borderTopRightRadius: 0,
|
|
borderBottomLeftRadius: DT.radiusCard / 8,
|
|
borderBottomRightRadius: DT.radiusCard / 8,
|
|
border: '1px solid',
|
|
borderColor: DT.borderSubtle,
|
|
overflow: 'hidden',
|
|
background: '#fff'
|
|
}}
|
|
>
|
|
{isMobile ? (
|
|
rows.length === 0 && !isLoading ? (
|
|
<Stack alignItems="center" spacing={1.5} sx={{ py: 6, px: 2 }}>
|
|
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
|
<MdLocalOffer size={28} />
|
|
</Avatar>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
No pricing to show
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: DT.textSecondary, textAlign: 'center' }}>
|
|
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the catalog.'}
|
|
</Typography>
|
|
</Stack>
|
|
) : (
|
|
<MobileCardList scroll>
|
|
{rows.map((row, index) => (
|
|
<MobileCard
|
|
key={row.pricingid || `${row.appname}-${index}`}
|
|
accent={BRAND}
|
|
header={
|
|
<Stack direction="row" alignItems="center" justifyContent="space-between" spacing={1}>
|
|
<Stack direction="row" alignItems="center" spacing={1} sx={{ minWidth: 0 }}>
|
|
<AccentAvatar color={BRAND} size={36}>
|
|
<MdGroups size={18} />
|
|
</AccentAvatar>
|
|
<Stack sx={{ minWidth: 0 }}>
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{ fontWeight: 700, color: DT.textPrimary }}
|
|
noWrap
|
|
>
|
|
{row.appname || '—'}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
ID #{row.pricingid}
|
|
</Typography>
|
|
</Stack>
|
|
</Stack>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted, flexShrink: 0 }}>
|
|
{String(index + 1).padStart(2, '0')}
|
|
</Typography>
|
|
</Stack>
|
|
}
|
|
>
|
|
<Stack direction="row" spacing={0.75} sx={{ mt: 1, flexWrap: 'wrap', gap: 0.75 }}>
|
|
{row.applocation ? (
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
px: 1,
|
|
py: 0.375,
|
|
borderRadius: 999,
|
|
bgcolor: tint('#10b981'),
|
|
border: `1px solid ${edge('#10b981')}`,
|
|
color: '#10b981',
|
|
fontSize: 11,
|
|
fontWeight: 800
|
|
}}
|
|
>
|
|
<MdPlace size={12} /> {row.applocation}
|
|
</Box>
|
|
) : null}
|
|
<Box
|
|
sx={{
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: 0.5,
|
|
px: 1,
|
|
py: 0.375,
|
|
borderRadius: 999,
|
|
bgcolor: tint('#0ea5e9'),
|
|
border: `1px solid ${edge('#0ea5e9')}`,
|
|
color: '#0ea5e9',
|
|
fontSize: 11,
|
|
fontWeight: 800
|
|
}}
|
|
>
|
|
<MdSpeed size={12} /> {row.slab || '—'}
|
|
</Box>
|
|
</Stack>
|
|
|
|
<MobileFieldGrid>
|
|
<MobileField label="Base Price">
|
|
<MetricPill
|
|
color={BRAND}
|
|
icon={<MdPriceCheck size={12} />}
|
|
label={formatRupees(row.baseprice)}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label="Price / KM">
|
|
<MetricPill
|
|
color="#10b981"
|
|
icon={<MdAttachMoney size={12} />}
|
|
label={formatRupees(row.priceperkm)}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label="Min KM">
|
|
<MetricPill
|
|
color="#f59e0b"
|
|
icon={<MdStraighten size={12} />}
|
|
label={`${formatDecimal(row.minkm)} km`}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label="Max KM">
|
|
<MetricPill
|
|
color="#ef4444"
|
|
icon={<MdStraighten size={12} />}
|
|
label={`${formatDecimal(row.maxkm)} km`}
|
|
/>
|
|
</MobileField>
|
|
<MobileField label="Min Orders">
|
|
<Stack direction="row" alignItems="center" spacing={0.5}>
|
|
<MdReceiptLong size={14} color={DT.textMuted} />
|
|
<Typography variant="subtitle2" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
{row.minorder ?? '—'}
|
|
</Typography>
|
|
</Stack>
|
|
</MobileField>
|
|
</MobileFieldGrid>
|
|
</MobileCard>
|
|
))}
|
|
</MobileCardList>
|
|
)
|
|
) : (
|
|
<TableContainer
|
|
ref={containerRef}
|
|
sx={{
|
|
maxHeight: { xs: 'calc(100vh - 220px)', md: 'calc(100vh - 190px)' },
|
|
'&::-webkit-scrollbar': { width: 10, height: 10 },
|
|
'&::-webkit-scrollbar-thumb': {
|
|
backgroundColor: edge(BRAND),
|
|
borderRadius: 8,
|
|
'&:hover': { backgroundColor: BRAND }
|
|
},
|
|
'&::-webkit-scrollbar-track': { backgroundColor: DT.surfaceAlt }
|
|
}}
|
|
>
|
|
<Table stickyHeader sx={{ minWidth: { xs: 860, md: 1080 } }}>
|
|
<TableHead>
|
|
<TableRow
|
|
sx={{
|
|
'& th': {
|
|
backgroundColor: DT.surfaceAlt,
|
|
color: DT.textSecondary,
|
|
fontSize: { xs: 10, md: 11 },
|
|
fontWeight: 800,
|
|
letterSpacing: 0.6,
|
|
textTransform: 'uppercase',
|
|
whiteSpace: 'nowrap',
|
|
borderBottom: `1px solid ${DT.borderSubtle}`,
|
|
py: { xs: 1, md: 1.25 },
|
|
px: { xs: 1, md: 2 }
|
|
}
|
|
}}
|
|
>
|
|
<TableCell>#</TableCell>
|
|
<TableCell>Tenant</TableCell>
|
|
<TableCell>Zone</TableCell>
|
|
<TableCell>Slab</TableCell>
|
|
<TableCell align="center">Base Price</TableCell>
|
|
<TableCell align="center">Min KM</TableCell>
|
|
<TableCell align="center">Price / KM</TableCell>
|
|
<TableCell align="center">Max KM</TableCell>
|
|
<TableCell align="center">Min Orders</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
|
|
<TableBody>
|
|
{isLoading && <OrdersTableSkeleton col={5} />}
|
|
{rows.length === 0 && !isLoading ? (
|
|
<TableRow>
|
|
<TableCell colSpan={9} sx={{ py: 6 }}>
|
|
<Stack alignItems="center" spacing={1.5}>
|
|
<Avatar sx={{ width: 64, height: 64, bgcolor: soft('#94a3b8'), color: DT.textMuted }}>
|
|
<MdLocalOffer size={28} />
|
|
</Avatar>
|
|
<Typography variant="subtitle1" sx={{ fontWeight: 700, color: DT.textPrimary }}>
|
|
No pricing to show
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
{searchword ? 'Try a different keyword.' : 'Pick a zone above to load the catalog.'}
|
|
</Typography>
|
|
</Stack>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
rows.map((row, index) => (
|
|
<TableRow
|
|
key={row.pricingid || `${row.appname}-${index}`}
|
|
sx={{
|
|
transition: 'background-color 0.15s',
|
|
'& td': {
|
|
borderBottom: `1px solid ${DT.divider}`,
|
|
py: { xs: 1, md: 1.5 },
|
|
px: { xs: 1, md: 2 }
|
|
},
|
|
'&:hover': { backgroundColor: DT.surfaceAlt }
|
|
}}
|
|
>
|
|
<TableCell>
|
|
<Typography variant="caption" sx={{ fontWeight: 700, color: DT.textMuted }}>
|
|
{String(index + 1).padStart(2, '0')}
|
|
</Typography>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<Stack direction="row" alignItems="center" spacing={1}>
|
|
<AccentAvatar color={BRAND} size={36}>
|
|
<MdGroups size={18} />
|
|
</AccentAvatar>
|
|
<Stack>
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
|
>
|
|
{row.appname || '—'}
|
|
</Typography>
|
|
<Typography variant="caption" sx={{ color: DT.textSecondary }}>
|
|
ID #{row.pricingid}
|
|
</Typography>
|
|
</Stack>
|
|
</Stack>
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
{row.applocation ? (
|
|
<CategoryChip icon={<MdPlace size={12} />} label={row.applocation} />
|
|
) : (
|
|
<Typography variant="caption" sx={{ color: DT.textMuted }}>—</Typography>
|
|
)}
|
|
</TableCell>
|
|
|
|
<TableCell>
|
|
<CategoryChip icon={<MdSpeed size={12} />} label={row.slab || '—'} />
|
|
</TableCell>
|
|
|
|
<TableCell align="center">
|
|
<MetricPill
|
|
color={BRAND}
|
|
icon={<MdPriceCheck size={12} />}
|
|
label={formatRupees(row.baseprice)}
|
|
width={110}
|
|
/>
|
|
</TableCell>
|
|
|
|
<TableCell align="center">
|
|
<MetricPill
|
|
color="#f59e0b"
|
|
icon={<MdStraighten size={12} />}
|
|
label={`${formatDecimal(row.minkm)} km`}
|
|
width={90}
|
|
/>
|
|
</TableCell>
|
|
|
|
<TableCell align="center">
|
|
<MetricPill
|
|
color="#10b981"
|
|
icon={<MdAttachMoney size={12} />}
|
|
label={formatRupees(row.priceperkm)}
|
|
width={110}
|
|
/>
|
|
</TableCell>
|
|
|
|
<TableCell align="center">
|
|
<MetricPill
|
|
color="#ef4444"
|
|
icon={<MdStraighten size={12} />}
|
|
label={`${formatDecimal(row.maxkm)} km`}
|
|
width={90}
|
|
/>
|
|
</TableCell>
|
|
|
|
<TableCell align="center">
|
|
<Stack direction="row" alignItems="center" justifyContent="center" spacing={0.5}>
|
|
<MdReceiptLong size={14} color={DT.textMuted} />
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{ fontWeight: 700, color: DT.textPrimary, whiteSpace: 'nowrap' }}
|
|
>
|
|
{row.minorder ?? '—'}
|
|
</Typography>
|
|
</Stack>
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
</Paper>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default ClientsPricing;
|