871 lines
36 KiB
JavaScript
871 lines
36 KiB
JavaScript
import { useState, useEffect } from 'react';
|
|
import { fetchUsers, deleteCompetitorBranch } from '@/utils/apiClient';
|
|
import {
|
|
Search,
|
|
Edit,
|
|
Trash2,
|
|
Phone,
|
|
MapPin,
|
|
FileText,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Store,
|
|
Truck,
|
|
Map,
|
|
Plus,
|
|
X,
|
|
CheckCircle2,
|
|
AlertCircle
|
|
} from 'lucide-react';
|
|
import { Button } from '@astryxdesign/core/Button';
|
|
import { Card } from '@astryxdesign/core/Card';
|
|
import { Dialog, DialogHeader } from '@astryxdesign/core/Dialog';
|
|
import { Layout, LayoutContent, LayoutFooter } from '@astryxdesign/core/Layout';
|
|
import { FormLayout } from '@astryxdesign/core/FormLayout';
|
|
import { TextInput } from '@astryxdesign/core/TextInput';
|
|
import { TextArea } from '@astryxdesign/core/TextArea';
|
|
import { Selector } from '@astryxdesign/core/Selector';
|
|
import { AlertDialog } from '@astryxdesign/core/AlertDialog';
|
|
import { Text, Heading } from '@astryxdesign/core/Text';
|
|
import { Badge } from '@astryxdesign/core/Badge';
|
|
import { IconButton } from '@astryxdesign/core/IconButton';
|
|
import { Banner } from '@astryxdesign/core/Banner';
|
|
|
|
import PageHeader from '@/components/PageHeader';
|
|
import StatCard from '@/components/StatCard';
|
|
import EmptyState from '@/components/EmptyState';
|
|
|
|
function isQuoted(row) {
|
|
return !!(row.rate_per_kg && !String(row.rate_per_kg).toLowerCase().includes('not answered'));
|
|
}
|
|
|
|
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}` } : {})
|
|
};
|
|
};
|
|
|
|
function Pill({ label, color = 'neutral', truncate = false }) {
|
|
if (!label) return <span style={{ fontWeight: 500, color: '#94a3b8' }}>—</span>;
|
|
|
|
const badgeStyles = {
|
|
success: { bg: '#E3F6EC', fg: '#00773B' },
|
|
warning: { bg: '#FFF7E0', fg: '#8A6500' },
|
|
info: { bg: '#E0F7F8', fg: '#00727B' },
|
|
error: { bg: '#FEEAE9', fg: '#A82216' },
|
|
primary: { bg: '#F8E0E3', fg: '#9E0E20' },
|
|
neutral: { bg: '#f1f5f9', fg: '#475569' }
|
|
};
|
|
|
|
const style = badgeStyles[color] || badgeStyles.neutral;
|
|
|
|
return (
|
|
<span
|
|
title={truncate ? label : undefined}
|
|
style={{
|
|
display: 'inline-block',
|
|
maxWidth: truncate ? '100%' : undefined,
|
|
overflow: truncate ? 'hidden' : undefined,
|
|
textOverflow: truncate ? 'ellipsis' : undefined,
|
|
whiteSpace: truncate ? 'nowrap' : undefined,
|
|
verticalAlign: truncate ? 'bottom' : undefined,
|
|
boxSizing: 'border-box',
|
|
padding: '2px 8px',
|
|
borderRadius: '4px',
|
|
fontSize: '0.75rem',
|
|
fontWeight: 600,
|
|
backgroundColor: style.bg,
|
|
color: style.fg
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function Field({ label, children }) {
|
|
return (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
|
|
<span style={{ textTransform: 'uppercase', letterSpacing: '0.05em', fontSize: '0.62rem', fontWeight: 700, color: '#475569' }}>{label}</span>
|
|
<div>{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SectionCard({ icon: Icon, title, children }) {
|
|
return (
|
|
<div style={{ height: '100%', borderRadius: '8px', border: '1px solid #e2e8f0', backgroundColor: '#ffffff', overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', gap: '6px', padding: '6px 10px', borderBottom: '1px solid #e2e8f0', backgroundColor: 'rgba(192, 18, 39, 0.08)' }}>
|
|
<Icon size={12} style={{ color: '#c01227' }} />
|
|
<span style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.64rem', letterSpacing: '0.6px', textTransform: 'uppercase' }}>{title}</span>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '8px 10px' }}>{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 url = `${API_BASE}/admin/carrier-pricing`;
|
|
const method = '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 set = (field) => (val) => {
|
|
setFormData(prev => ({ ...prev, [field]: val }));
|
|
};
|
|
|
|
const handleSlabChange = (index, field) => (val) => {
|
|
const newSlabs = [...(formData.slabs || [])];
|
|
newSlabs[index] = { ...newSlabs[index], [field]: val };
|
|
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) => (val) => {
|
|
const newPincodes = [...(formData.pincodes || [])];
|
|
newPincodes[index] = val;
|
|
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: ${invalidPincodes.join(', ')}. Must be 6 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);
|
|
}
|
|
}
|
|
|
|
onSave();
|
|
onClose();
|
|
} catch (err) {
|
|
console.error(err);
|
|
setError(err.message || 'Failed to save. Please try again.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const YES_NO = [{ value: 'yes', label: 'Yes' }, { value: 'no', label: 'No' }];
|
|
const FREQUENCY_OPTIONS = ['Daily', 'Weekly', 'Bi-weekly', 'Monthly', 'On-demand'];
|
|
const WEIGHT_SLAB_OPTIONS = WEIGHT_SLABS.map((s) => ({ value: s, label: s }));
|
|
const ZONE_OPTIONS = ZONES.map((z) => ({ value: z, label: z }));
|
|
|
|
return (
|
|
<Dialog isOpen={open} onOpenChange={(o) => { if (!o) onClose(); }} width={600} maxHeight="90dvh" purpose="form">
|
|
<Layout
|
|
header={<DialogHeader title={initialData ? 'Edit Survey Record' : 'Add New Survey'} onOpenChange={(o) => { if (!o) onClose(); }} />}
|
|
content={
|
|
<LayoutContent>
|
|
<FormLayout>
|
|
{error && <Banner status="error" title={error} />}
|
|
|
|
<FormLayout direction="horizontal">
|
|
<TextInput label="Company" value={formData.company || ''} onChange={set('company')} />
|
|
<TextInput label="Area / Zone" value={formData.area || ''} onChange={set('area')} />
|
|
</FormLayout>
|
|
<FormLayout direction="horizontal">
|
|
<TextInput label="Phone" value={formData.phone || ''} onChange={set('phone')} />
|
|
<TextInput label="Rate Per KG" value={formData.rate_per_kg || ''} onChange={set('rate_per_kg')} />
|
|
</FormLayout>
|
|
<FormLayout direction="horizontal">
|
|
<Selector label="Offers Pickup" options={YES_NO} value={formData.offers_pickup || 'no'} onChange={set('offers_pickup')} />
|
|
<Selector label="Offers Drop" options={YES_NO} value={formData.offers_drop || 'no'} onChange={set('offers_drop')} />
|
|
</FormLayout>
|
|
<FormLayout direction="horizontal">
|
|
<TextInput label="Time in Days" value={formData.time_in_days || ''} onChange={set('time_in_days')} />
|
|
<TextInput label="Packing Charge" value={formData.packing_charge || ''} onChange={set('packing_charge')} />
|
|
</FormLayout>
|
|
<FormLayout direction="horizontal">
|
|
<Selector
|
|
label="Frequency"
|
|
placeholder="Select Frequency"
|
|
hasClear
|
|
options={FREQUENCY_OPTIONS}
|
|
value={formData.frequency || ''}
|
|
onChange={(v) => set('frequency')(v ?? '')}
|
|
/>
|
|
<TextInput label="Plus Code" value={formData.plus_code || ''} onChange={set('plus_code')} />
|
|
</FormLayout>
|
|
|
|
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '16px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#C01227' }}>SERVICEABLE PINCODES</span>
|
|
<Button size="sm" variant="secondary" icon={<Plus size={12} />} onClick={addPincode} label="Add Pincode" />
|
|
</div>
|
|
{(formData.pincodes || []).map((pincode, index) => (
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', marginBottom: '8px' }} key={index}>
|
|
<div style={{ flexGrow: 1 }}>
|
|
<TextInput
|
|
label={`Pincode ${index + 1}`}
|
|
isLabelHidden
|
|
placeholder={`Pincode ${index + 1}`}
|
|
value={pincode}
|
|
onChange={handlePincodeChange(index)}
|
|
maxLength={6}
|
|
/>
|
|
</div>
|
|
<button onClick={() => removePincode(index)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#dc2626', padding: '8px' }}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<TextArea label="Address" rows={2} value={formData.address || ''} onChange={set('address')} />
|
|
|
|
{/* Pricing slabs */}
|
|
<div style={{ borderTop: '1px dashed #e2e8f0', paddingTop: '16px' }}>
|
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '12px' }}>
|
|
<span style={{ fontSize: '0.875rem', fontWeight: 700, color: '#C01227' }}>PRICING SLABS (OPTIONAL)</span>
|
|
<Button size="sm" variant="secondary" icon={<Plus size={12} />} onClick={addSlab} label="Add Slab" />
|
|
</div>
|
|
{(formData.slabs || []).map((slab, index) => (
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', marginBottom: '8px', flexWrap: 'wrap' }} key={index}>
|
|
<div style={{ width: '140px' }}>
|
|
<Selector
|
|
label="Weight Slab" isLabelHidden placeholder="Weight Slab" hasClear
|
|
options={WEIGHT_SLAB_OPTIONS} value={slab.weight_slab || ''}
|
|
onChange={(v) => handleSlabChange(index, 'weight_slab')(v ?? '')}
|
|
/>
|
|
</div>
|
|
<div style={{ width: '140px' }}>
|
|
<Selector
|
|
label="Zone" isLabelHidden placeholder="Zone" hasClear
|
|
options={ZONE_OPTIONS} value={slab.zone || ''}
|
|
onChange={(v) => handleSlabChange(index, 'zone')(v ?? '')}
|
|
/>
|
|
</div>
|
|
<div style={{ flexGrow: 1, minWidth: '140px' }}>
|
|
<TextInput label="Service Type" isLabelHidden placeholder="Service Type" value={slab.service_type || ''} onChange={handleSlabChange(index, 'service_type')} />
|
|
</div>
|
|
<div style={{ width: '90px' }}>
|
|
<TextInput label="Rate" isLabelHidden placeholder="Rate" value={slab.rate || ''} onChange={handleSlabChange(index, 'rate')} />
|
|
</div>
|
|
<button onClick={() => removeSlab(index)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#dc2626', padding: '8px' }}>
|
|
<X size={16} />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</FormLayout>
|
|
</LayoutContent>
|
|
}
|
|
footer={
|
|
<LayoutFooter hasDivider>
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '12px' }}>
|
|
<Button label="Cancel" variant="ghost" onClick={onClose} isDisabled={saving} />
|
|
<Button
|
|
label="Save Record"
|
|
variant="primary"
|
|
onClick={handleSave}
|
|
isDisabled={saving}
|
|
isLoading={saving}
|
|
/>
|
|
</div>
|
|
</LayoutFooter>
|
|
}
|
|
/>
|
|
</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);
|
|
const quoted = isQuoted(row);
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
padding: '8px 10px',
|
|
borderRadius: '8px',
|
|
border: '1px solid #e2e8f0',
|
|
borderLeft: `3px solid ${quoted ? '#10b981' : '#f59e0b'}`,
|
|
backgroundColor: '#ffffff',
|
|
transition: 'border-color 0.2s, box-shadow 0.2s',
|
|
marginBottom: '6px'
|
|
}}
|
|
className="branch-card"
|
|
>
|
|
<div style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '10px' }} onClick={() => setOpen(!open)}>
|
|
|
|
{/* Left: Location & Contact */}
|
|
<div style={{ display: 'flex', gap: '8px', alignItems: 'center', minWidth: '200px' }}>
|
|
<div style={{ padding: '6px', borderRadius: '6px', backgroundColor: 'rgba(192, 18, 39, 0.05)', color: '#c01227', display: 'flex' }}>
|
|
<MapPin size={14} />
|
|
</div>
|
|
<div>
|
|
<div style={{ fontWeight: 700, color: '#1e293b', fontSize: '0.8rem', display: 'flex', alignItems: 'center', gap: '4px' }}>
|
|
{row.area || 'Unknown Area'}
|
|
{quoted ? (
|
|
<CheckCircle2 size={11} style={{ color: '#10b981', flexShrink: 0 }} />
|
|
) : (
|
|
<AlertCircle size={11} style={{ color: '#f59e0b', flexShrink: 0 }} />
|
|
)}
|
|
</div>
|
|
<div style={{ color: '#64748b', fontSize: '0.72rem', display: 'flex', alignItems: 'center', gap: '4px', marginTop: '2px' }}>
|
|
<Phone size={10} /> {row.phone || '—'}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Middle: Rates */}
|
|
<div style={{ display: 'flex', gap: '16px', flexGrow: 1 }}>
|
|
<div style={{ width: '130px', flexShrink: 0, overflow: 'hidden' }}>
|
|
<span style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 700, letterSpacing: '0.05em', display: 'block', marginBottom: '2px', textTransform: 'uppercase' }}>Rate/kg</span>
|
|
<Pill label={row.rate_per_kg} color={quoted ? 'success' : 'warning'} truncate />
|
|
</div>
|
|
<div style={{ width: '90px', flexShrink: 0, overflow: 'hidden' }}>
|
|
<span style={{ fontSize: '0.6rem', color: '#64748b', fontWeight: 700, letterSpacing: '0.05em', display: 'block', marginBottom: '2px', textTransform: 'uppercase' }}>Logistics</span>
|
|
<span style={{ fontWeight: 600, color: '#1e293b', fontSize: '0.8rem', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{row.offers_pickup === 'yes' || row.offers_drop === 'yes' ? `${row.offers_pickup === 'yes' ? 'Pickup' : ''} ${row.offers_drop === 'yes' ? 'Drop' : ''}` : '—'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right: Actions */}
|
|
<div style={{ display: 'flex', gap: '4px', alignItems: 'center' }} onClick={(e) => e.stopPropagation()}>
|
|
<Button size="sm" variant="secondary" label={open ? 'Hide Info' : 'More Info'} onClick={() => setOpen(!open)} />
|
|
<button onClick={() => onEdit(row)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '5px', borderRadius: '4px', color: '#475569' }} className="action-btn">
|
|
<Edit size={14} />
|
|
</button>
|
|
<button onClick={() => onDelete(row)} style={{ background: 'none', border: 'none', cursor: 'pointer', padding: '5px', borderRadius: '4px', color: '#dc2626' }} className="action-btn">
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{open && (
|
|
<div style={{ marginTop: '8px', paddingTop: '8px', borderTop: '1px dashed #e2e8f0' }}>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '8px' }}>
|
|
<SectionCard icon={Store} title="ENQUIRY DETAILS">
|
|
<Field label="RATE PER KG">
|
|
<Pill label={row.rate_per_kg} color="success" />
|
|
</Field>
|
|
<Field label="PICKUP">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.offers_pickup || '—'}</span>
|
|
</Field>
|
|
<Field label="DROP">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.offers_drop || '—'}</span>
|
|
</Field>
|
|
<Field label="PACKING">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.packing_charge || '—'}</span>
|
|
</Field>
|
|
</SectionCard>
|
|
|
|
<SectionCard icon={Truck} title="LOGISTICS & OPS">
|
|
<Field label="TIME IN DAYS">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.time_in_days || '—'}</span>
|
|
</Field>
|
|
<Field label="COMPANY">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.company || '—'}</span>
|
|
</Field>
|
|
<Field label="FREQUENCY">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.frequency || '—'}</span>
|
|
</Field>
|
|
<Field label="CONTACT NUMBER">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.phone || '—'}</span>
|
|
</Field>
|
|
</SectionCard>
|
|
|
|
<SectionCard icon={MapPin} title="LOCATION & PINCODE">
|
|
<Field label="AREA / ZONE">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.area || '—'}</span>
|
|
</Field>
|
|
<Field label="SERVICEABLE PINCODES">
|
|
{row.pincodes ? (
|
|
<div style={{ display: 'flex', gap: '4px', flexWrap: 'wrap', marginTop: '4px' }}>
|
|
{row.pincodes.split(',').map((p, i) => (
|
|
<span key={i} style={{ fontSize: '0.7rem', padding: '2px 6px', backgroundColor: '#f1f5f9', color: '#475569', borderRadius: '4px' }}>{p.trim()}</span>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<span style={{ fontSize: '0.78rem', color: '#94a3b8' }}>—</span>
|
|
)}
|
|
</Field>
|
|
<Field label="PLUS CODE">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>{row.plus_code || '—'}</span>
|
|
</Field>
|
|
<Field label="FULL ADDRESS">
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
|
<div style={{ display: 'flex', gap: '6px', padding: '8px', borderRadius: '6px', backgroundColor: '#f8fafc', border: '1px solid #e2e8f0' }}>
|
|
<MapPin size={12} style={{ color: '#94a3b8', marginTop: '2px' }} />
|
|
<span style={{ color: '#1e293b', fontSize: '0.78rem', lineHeight: 1.4 }}>{row.address || '—'}</span>
|
|
</div>
|
|
{(row.address || row.plus_code) && (
|
|
<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 noreferrer"
|
|
style={{
|
|
alignSelf: 'flex-start',
|
|
padding: '4px 10px',
|
|
fontSize: '0.7rem',
|
|
fontWeight: 600,
|
|
borderRadius: '4px',
|
|
color: '#c01227',
|
|
border: '1px solid rgba(192, 18, 39, 0.2)',
|
|
backgroundColor: 'rgba(192, 18, 39, 0.05)',
|
|
textDecoration: 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '4px'
|
|
}}
|
|
>
|
|
<Map size={12} />
|
|
View on map
|
|
</a>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
</SectionCard>
|
|
</div>
|
|
|
|
<div style={{ marginTop: '8px' }}>
|
|
<SectionCard icon={FileText} title="RECORD METADATA">
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(160px, 1fr))', gap: '8px' }}>
|
|
<Field label="CREATED BY">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>
|
|
{creator ? creator.first_name : (row.created_by ? `User ID: ${row.created_by}` : 'System')}
|
|
</span>
|
|
</Field>
|
|
<Field label="LAST EDITED BY">
|
|
<span style={{ fontSize: '0.78rem', fontWeight: 500, color: '#1e293b' }}>
|
|
{updater ? updater.first_name : (row.updated_by ? `User ID: ${row.updated_by}` : '—')}
|
|
</span>
|
|
</Field>
|
|
</div>
|
|
</SectionCard>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompanyGroup({ companyName, branches, onEdit, onDelete, users, isLast }) {
|
|
const [open, setOpen] = useState(false);
|
|
const quotedCount = branches.filter(isQuoted).length;
|
|
const totalCount = branches.length;
|
|
const allQuoted = quotedCount === totalCount;
|
|
const noneQuoted = quotedCount === 0;
|
|
const quotedVariant = allQuoted ? 'success' : noneQuoted ? 'error' : 'warning';
|
|
|
|
return (
|
|
<div style={{ borderBottom: isLast ? 'none' : '1px solid rgba(5, 54, 89, 0.06)' }}>
|
|
<div
|
|
style={{ display: 'flex', flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', flexWrap: 'wrap', gap: '12px', padding: '10px 16px', cursor: 'pointer' }}
|
|
onClick={() => setOpen(!open)}
|
|
>
|
|
<div style={{ display: 'flex', gap: '10px', alignItems: 'center' }}>
|
|
<div style={{ width: '32px', height: '32px', borderRadius: '8px', backgroundColor: 'rgba(192, 18, 39, 0.08)', color: '#C01227', fontWeight: 700, fontSize: '0.8rem', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
|
|
{(companyName || 'A')[0].toUpperCase()}
|
|
</div>
|
|
<div>
|
|
<Text type="body" weight="semibold">{companyName || 'Unknown Company'}</Text>
|
|
<div style={{ display: 'flex', gap: '6px', alignItems: 'center', marginTop: '2px', flexWrap: 'wrap' }}>
|
|
<div style={{ display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
|
|
<Store size={11} style={{ color: '#94a3b8' }} />
|
|
<Text type="supporting" color="secondary">{branches.length} location{branches.length !== 1 && 's'}</Text>
|
|
</div>
|
|
<Badge
|
|
variant={quotedVariant}
|
|
icon={allQuoted ? <CheckCircle2 size={11} /> : <AlertCircle size={11} />}
|
|
label={`${quotedCount}/${totalCount} quoted`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<IconButton
|
|
label={open ? 'Hide locations' : 'View locations'}
|
|
tooltip={open ? 'Hide locations' : 'View locations'}
|
|
variant="secondary"
|
|
size="sm"
|
|
icon={open ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
|
|
/>
|
|
</div>
|
|
|
|
{open && (
|
|
<div style={{ padding: '0 16px 10px 16px', backgroundColor: '#f8fafc' }}>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', paddingTop: '4px' }}>
|
|
{branches.map((branch, idx) => <BranchCard key={branch.id ?? idx} row={branch} onEdit={onEdit} onDelete={onDelete} users={users} />)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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())
|
|
);
|
|
|
|
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 (
|
|
<div style={{ fontFamily: 'system-ui, sans-serif' }}>
|
|
<PageHeader
|
|
title="Field Surveys"
|
|
action={
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', flexWrap: 'nowrap' }}>
|
|
<div style={{ flex: '0 1 320px', minWidth: '200px' }}>
|
|
<TextInput
|
|
label="Search survey records"
|
|
isLabelHidden
|
|
placeholder="Search surveys…"
|
|
startIcon={<Search size={16} />}
|
|
hasClear
|
|
value={search}
|
|
onChange={(v) => { setSearch(v); setPage(0); }}
|
|
/>
|
|
</div>
|
|
<Button
|
|
variant="primary"
|
|
icon={<Plus size={14} />}
|
|
onClick={() => { setEditingRecord(null); setFormOpen(true); }}
|
|
label="Add New Survey"
|
|
style={{ flexShrink: 0 }}
|
|
/>
|
|
</div>
|
|
}
|
|
/>
|
|
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: '16px', marginBottom: '24px', marginTop: '16px' }}>
|
|
<StatCard
|
|
size="sm"
|
|
title="TOTAL ENQUIRIES"
|
|
value={loading ? '...' : stats.total}
|
|
icon={FileText}
|
|
color="primary"
|
|
/>
|
|
<StatCard
|
|
size="sm"
|
|
title="RATES QUOTED"
|
|
value={loading ? '...' : stats.quoted}
|
|
icon={Store}
|
|
color="success"
|
|
/>
|
|
<StatCard
|
|
size="sm"
|
|
title="MISSING INFO"
|
|
value={loading ? '...' : stats.missing}
|
|
icon={Phone}
|
|
color="warning"
|
|
/>
|
|
</div>
|
|
|
|
<Card style={{ padding: '0', borderRadius: '16px', border: '1px solid rgba(5, 54, 89, 0.08)', backgroundColor: '#ffffff', overflow: 'hidden', boxShadow: '0 10px 30px -5px rgba(0,0,0,0.02), 0 4px 12px -3px rgba(0,0,0,0.02)' }}>
|
|
|
|
{/* List Section */}
|
|
{loading ? (
|
|
<div style={{ padding: '20px 24px' }}>
|
|
{[0, 1, 2].map((i) => (
|
|
<div key={i} className="skeleton-pulse" style={{ height: '64px', borderRadius: '10px', backgroundColor: '#f1f5f9', marginBottom: '10px' }} />
|
|
))}
|
|
</div>
|
|
) : paginatedKeys.length > 0 ? (
|
|
<div>
|
|
{paginatedKeys.map((companyName, idx) => (
|
|
<CompanyGroup
|
|
key={companyName}
|
|
companyName={companyName}
|
|
branches={groupedData[companyName]}
|
|
onEdit={(row) => { setEditingRecord(row); setFormOpen(true); }}
|
|
onDelete={(row) => setToDelete(row)}
|
|
users={users}
|
|
isLast={idx === paginatedKeys.length - 1}
|
|
/>
|
|
))}
|
|
</div>
|
|
) : search ? (
|
|
<div style={{ padding: '32px 24px' }}>
|
|
<EmptyState
|
|
icon={Search}
|
|
title="No matching records"
|
|
caption={`Nothing matches "${search}". Try a different company, area or phone number.`}
|
|
/>
|
|
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'center' }}>
|
|
<Button label="Clear search" variant="secondary" onClick={() => { setSearch(''); setPage(0); }} />
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<div style={{ padding: '32px 24px' }}>
|
|
<EmptyState
|
|
icon={FileText}
|
|
title="No survey records yet"
|
|
caption="Field surveys you add will show up here, grouped by company."
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination Section */}
|
|
<div style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', padding: '12px 24px', borderTop: '1px solid #e2e8f0', gap: '20px', flexWrap: 'wrap', backgroundColor: '#ffffff' }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '0.875rem', color: '#475569' }}>
|
|
<span>Companies per page:</span>
|
|
<select
|
|
value={rowsPerPage}
|
|
onChange={(e) => { setRowsPerPage(Number(e.target.value)); setPage(0); }}
|
|
className="rpp-select"
|
|
>
|
|
{[5, 10, 25].map((opt) => <option key={opt} value={opt}>{opt}</option>)}
|
|
</select>
|
|
</div>
|
|
<span style={{ fontSize: '0.875rem', color: '#475569' }}>
|
|
{companyKeys.length === 0 ? '0-0' : `${page * rowsPerPage + 1}-${Math.min((page + 1) * rowsPerPage, companyKeys.length)}`} of {companyKeys.length}
|
|
</span>
|
|
<div style={{ display: 'flex', gap: '8px' }}>
|
|
<Button
|
|
label="Prev"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
|
isDisabled={page === 0}
|
|
/>
|
|
<Button
|
|
label="Next"
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.min(Math.ceil(companyKeys.length / rowsPerPage) - 1, p + 1))}
|
|
isDisabled={page >= Math.ceil(companyKeys.length / rowsPerPage) - 1}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
|
|
<SurveyFormDialog
|
|
open={formOpen}
|
|
onClose={() => setFormOpen(false)}
|
|
onSave={() => loadData()}
|
|
initialData={editingRecord}
|
|
/>
|
|
|
|
<AlertDialog
|
|
isOpen={!!toDelete}
|
|
onOpenChange={(o) => { if (!o) { setToDelete(null); setDeleteError(null); } }}
|
|
title="Delete survey record?"
|
|
description={
|
|
toDelete
|
|
? (deleteError || `This will permanently remove the record for ${toDelete.area || toDelete.company}. This cannot be undone.`)
|
|
: ''
|
|
}
|
|
actionLabel="Delete"
|
|
onAction={confirmDelete}
|
|
isActionLoading={deleting}
|
|
/>
|
|
|
|
<style>{`
|
|
.action-btn:hover {
|
|
background-color: #f1f5f9;
|
|
}
|
|
.branch-card:hover {
|
|
border-color: #cbd5e1;
|
|
box-shadow: 0 4px 12px rgba(15, 23, 42, 0.06);
|
|
}
|
|
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
|
|
@keyframes skeletonPulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } }
|
|
.skeleton-pulse { animation: skeletonPulse 1.4s ease-in-out infinite; }
|
|
`}</style>
|
|
</div>
|
|
);
|
|
}
|