import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Mail, Package, Pencil, Phone, Plus, Users } from 'lucide-react'; import { Avatar, Button, DataTable, EmptyState, Field, Grid, IconButton, Input, KpiCard, Modal, Stack, } from '@/components/ds'; import { ListToolbar } from '@/components/doormile/ListToolbar'; import { OpenToast } from '@/components/third-party/OpenToast'; import { useTenantCustomers, useUpdateTenantCustomer } from '@/lib/doormileHooks'; import { matchesQuery, number, percentOf, useDebouncedValue } from '@/lib/doormileFormat'; /** * Customers — the end customers a client's bookings are delivered to. * * The identifier and name fields differ between the two customer resources * this list can be fed from, so both are read through the accessors below * rather than assumed: a row that arrives as `firstname`/`lastname` and one * that arrives as `name` have to render identically. */ const custId = (row) => row.appcustomerid ?? row.tenantcustomerid ?? row.customerid ?? row.id; const custName = (row) => row.name || [row.firstname, row.lastname].filter(Boolean).join(' ') || '—'; const custPhone = (row) => row.phone || row.contactno || ''; const custEmail = (row) => row.email || ''; /** Address accessor for search and preview */ const custAddress = (row) => [row.address, row.deliveryaddress, row.suburb, row.city, row.postcode].filter(Boolean).join(', '); /** Tenant customers carry no booking count; 0 is honest rather than blank. */ const custBookings = (row) => row.totalbookings ?? 0; const readSavedContacts = () => { try { const data = localStorage.getItem('doormile_saved_contacts'); return data ? JSON.parse(data) : []; } catch { return []; } }; export default function Customers({ embedded = false }) { const navigate = useNavigate(); const { data: customers = [], isLoading, isFetching } = useTenantCustomers(); const updateCustomer = useUpdateTenantCustomer(); const [localSavedContacts, setLocalSavedContacts] = useState(readSavedContacts); useEffect(() => { const handleUpdate = () => setLocalSavedContacts(readSavedContacts()); window.addEventListener('doormile_saved_contacts_updated', handleUpdate); window.addEventListener('storage', handleUpdate); window.addEventListener('focus', handleUpdate); return () => { window.removeEventListener('doormile_saved_contacts_updated', handleUpdate); window.removeEventListener('storage', handleUpdate); window.removeEventListener('focus', handleUpdate); }; }, []); const mergedCustomers = useMemo(() => { const list = [...(customers || [])]; const seen = new Set(); const result = []; for (const sc of localSavedContacts) { const p = String(sc.phone || sc.contactno || '').replace(/\D/g, '').slice(-10); if (p) seen.add(p); result.push({ ...sc, appcustomerid: sc.id || `saved_${p}`, name: sc.name || sc.firstname, phone: sc.phone || sc.contactno, totalbookings: 0 }); } for (const c of list) { const p = String(c.phone || c.contactno || '').replace(/\D/g, '').slice(-10); if (p && seen.has(p)) continue; if (p) seen.add(p); result.push(c); } return result; }, [customers, localSavedContacts]); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search); const [activeFilter, setActiveFilter] = useState('all'); const [editRow, setEditRow] = useState(null); const [form, setForm] = useState({ name: '', phone: '', email: '' }); const stats = useMemo(() => { const total = mergedCustomers.length; return { total, withEmail: mergedCustomers.filter((row) => Boolean(row.email)).length, withPhone: mergedCustomers.filter((row) => Boolean(row.phone)).length, totalBookings: mergedCustomers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0), }; }, [mergedCustomers]); const rows = useMemo(() => { let list = mergedCustomers; if (activeFilter === 'withEmail') list = list.filter((row) => Boolean(row.email)); else if (activeFilter === 'withPhone') list = list.filter((row) => Boolean(row.phone)); return list.filter((row) => matchesQuery(row, [custName, custPhone, custEmail, custAddress], debouncedSearch)); }, [mergedCustomers, activeFilter, debouncedSearch]); const toggleFilter = (key) => setActiveFilter((current) => (current === key ? 'all' : key)); const openEdit = (row) => { setEditRow(row); setForm({ name: custName(row) === '—' ? '' : custName(row), phone: custPhone(row), email: custEmail(row) }); }; const save = () => { const id = custId(editRow); const name = String(form.name || '').trim(); const cleanPhone = String(form.phone || '').replace(/\D/g, '').replace(/^(?:91|0)(?=\d{10}$)/, ''); if (name.length < 2) { OpenToast('Enter the customer’s name (at least 2 letters).', 'warning', 4000); return; } if (!/^\d{10}$/.test(cleanPhone)) { OpenToast('Mobile number must be exactly 10 digits, without +91 or a leading 0.', 'warning', 4000); return; } // A contact saved from Create Order lives only in this browser, so it is // edited in localStorage rather than through the API. if (String(id).startsWith('saved_')) { try { const stored = JSON.parse(localStorage.getItem('doormile_saved_contacts') || '[]'); const isThis = (item) => item.id === id || item.appcustomerid === id; // Match on the id ONLY. Matching on the new phone number as well used // to overwrite a second, unrelated contact whenever the number was // changed to one already on file. const clash = stored.find( (item) => !isThis(item) && String(item.phone || item.contactno || '').replace(/\D/g, '').slice(-10) === cleanPhone ); if (clash) { OpenToast( `${clash.name || clash.firstname || 'Another saved contact'} already uses ${cleanPhone}. Use a different number, or edit that contact instead.`, 'warning', 6000 ); return; } const updated = stored.map((item) => isThis(item) ? { ...item, name, firstname: name, phone: cleanPhone, contactno: cleanPhone, email: String(form.email || '').trim(), updatedAt: new Date().toISOString() } : item ); localStorage.setItem('doormile_saved_contacts', JSON.stringify(updated)); window.dispatchEvent(new Event('doormile_saved_contacts_updated')); OpenToast(`${name}'s details have been updated.`, 'success', 3000); setEditRow(null); } catch { OpenToast("We couldn't save this change in your browser. Please try again, or check that site storage isn't blocked.", 'error', 6000); } return; } /* The record stores firstname/lastname but the dialog edits one name field. Splitting on the first space keeps the surname through a round trip instead of blanking it. */ const [firstname, ...rest] = name.split(/\s+/); updateCustomer.mutate( { id, data: { firstname: firstname || '', lastname: rest.join(' '), phone: cleanPhone, email: form.email || '', }, }, { onSuccess: (res) => res?.success !== false && setEditRow(null) } ); }; const columns = useMemo( () => [ { key: 'customer', header: 'Customer', sortable: true, accessor: custName, cell: (row) => { const addr = custAddress(row); const isSaved = row.isSavedForLater || String(custId(row)).startsWith('saved_'); return (

{custName(row)}

{isSaved && ( Saved )}

{addr || `ID #${custId(row) ?? '—'}`}

); }, }, { key: 'contact', header: 'Contact', sortable: true, accessor: custPhone, cell: (row) => (

{custEmail(row) ? (

) : null}
), }, { key: 'bookings', header: 'Bookings', sortValue: custBookings, accessor: (row) => number(custBookings(row), '0'), align: 'right', hideBelow: 'md', }, { key: 'actions', header: 'Actions', align: 'right', cell: (row) => (
openEdit(row)} />
), }, ], [] ); return ( setActiveFilter('all')} /> toggleFilter('withEmail')} /> toggleFilter('withPhone')} /> navigate('/doormile/customer/create')}> New customer } /> custId(row)} loading={isLoading} refreshing={isFetching && !isLoading} pageSize={10} defaultSort={{ key: 'customer', direction: 'asc' }} isFiltered={Boolean(debouncedSearch) || activeFilter !== 'all'} onClearFilters={() => { setSearch(''); setActiveFilter('all'); }} emptyState={ debouncedSearch || activeFilter !== 'all' ? undefined : ( navigate('/doormile/customer/create') }} /> ) } /> !open && setEditRow(null)} title="Edit customer" description={`ID #${custId(editRow || {}) ?? ''}`} icon={Users} busy={updateCustomer.isPending} footer={ <> } > setForm({ ...form, name: e.target.value })} placeholder="Anitha Raman" /> setForm({ ...form, phone: e.target.value })} placeholder="9876543210" /> setForm({ ...form, email: e.target.value })} placeholder="anitha@example.com" /> ); }