Files
doormile_crm/src/pages/survey/Survey.jsx
2026-07-14 12:20:50 +05:30

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>
);
}