378 lines
14 KiB
JavaScript
378 lines
14 KiB
JavaScript
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 (
|
||
<div className="flex items-center gap-2.5 min-w-0">
|
||
<Avatar name={custName(row)} size="sm" />
|
||
<div className="min-w-0">
|
||
<div className="flex items-center gap-1.5">
|
||
<p className="truncate text-body-sm font-semibold text-ink-1">{custName(row)}</p>
|
||
{isSaved && (
|
||
<span className="text-[10px] bg-emerald-50 text-emerald-700 border border-emerald-200 px-1.5 py-0.5 rounded font-semibold shrink-0">
|
||
Saved
|
||
</span>
|
||
)}
|
||
</div>
|
||
<p className="text-caption text-ink-3 truncate max-w-xs" title={addr || `ID #${custId(row) ?? '—'}`}>
|
||
{addr || `ID #${custId(row) ?? '—'}`}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
key: 'contact',
|
||
header: 'Contact',
|
||
sortable: true,
|
||
accessor: custPhone,
|
||
cell: (row) => (
|
||
<div className="min-w-0">
|
||
<p className="flex items-center gap-1.5 text-body-sm text-ink-1">
|
||
<Phone className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||
{custPhone(row) || '—'}
|
||
</p>
|
||
{custEmail(row) ? (
|
||
<p className="flex items-center gap-1.5 truncate text-caption text-ink-3">
|
||
<Mail className="h-3 w-3 shrink-0 text-ink-4" aria-hidden="true" />
|
||
{custEmail(row)}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
key: 'bookings',
|
||
header: 'Bookings',
|
||
sortValue: custBookings,
|
||
accessor: (row) => number(custBookings(row), '0'),
|
||
align: 'right',
|
||
hideBelow: 'md',
|
||
},
|
||
{
|
||
key: 'actions',
|
||
header: 'Actions',
|
||
align: 'right',
|
||
cell: (row) => (
|
||
<div className="flex justify-end">
|
||
<IconButton label="Edit customer" icon={Pencil} variant="ghost" size="sm" onClick={() => openEdit(row)} />
|
||
</div>
|
||
),
|
||
},
|
||
],
|
||
[]
|
||
);
|
||
|
||
return (
|
||
<Stack space="lg">
|
||
<Grid cols={4}>
|
||
<KpiCard
|
||
icon={Users}
|
||
value={stats.total}
|
||
label="Total customers"
|
||
tone="brand"
|
||
index={0}
|
||
loading={isLoading}
|
||
onClick={() => setActiveFilter('all')}
|
||
/>
|
||
<KpiCard
|
||
icon={Mail}
|
||
value={stats.withEmail}
|
||
label={`With email · ${percentOf(stats.withEmail, stats.total)}%`}
|
||
tone="navy"
|
||
index={1}
|
||
loading={isLoading}
|
||
onClick={() => toggleFilter('withEmail')}
|
||
/>
|
||
<KpiCard
|
||
icon={Phone}
|
||
value={stats.withPhone}
|
||
label={`With phone · ${percentOf(stats.withPhone, stats.total)}%`}
|
||
tone="accent"
|
||
index={2}
|
||
loading={isLoading}
|
||
onClick={() => toggleFilter('withPhone')}
|
||
/>
|
||
<KpiCard
|
||
icon={Package}
|
||
value={number(stats.totalBookings, '0')}
|
||
label="Bookings placed"
|
||
tone="success"
|
||
index={3}
|
||
loading={isLoading}
|
||
/>
|
||
</Grid>
|
||
|
||
<Stack space="sm">
|
||
<ListToolbar
|
||
search={search}
|
||
onSearchChange={setSearch}
|
||
placeholder="Search by name, phone, email or address"
|
||
count={{ shown: rows.length, total: mergedCustomers.length, noun: 'customers' }}
|
||
actions={
|
||
<Button onClick={() => navigate('/doormile/customer/create')}>
|
||
<Plus className="mr-1.5 h-4 w-4" /> New customer
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<DataTable
|
||
columns={columns}
|
||
rows={rows}
|
||
getRowId={(row) => 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 : (
|
||
<EmptyState
|
||
icon={Users}
|
||
title="No customers yet"
|
||
description="Customers appear here as bookings are placed, or add one directly."
|
||
action={{ label: 'New customer', icon: Plus, onClick: () => navigate('/doormile/customer/create') }}
|
||
/>
|
||
)
|
||
}
|
||
/>
|
||
</Stack>
|
||
|
||
<Modal
|
||
open={Boolean(editRow)}
|
||
onOpenChange={(open) => !open && setEditRow(null)}
|
||
title="Edit customer"
|
||
description={`ID #${custId(editRow || {}) ?? ''}`}
|
||
icon={Users}
|
||
busy={updateCustomer.isPending}
|
||
footer={
|
||
<>
|
||
<Button variant="ghost" onClick={() => setEditRow(null)}>
|
||
Cancel
|
||
</Button>
|
||
<Button onClick={save} disabled={updateCustomer.isPending}>
|
||
Save changes
|
||
</Button>
|
||
</>
|
||
}
|
||
>
|
||
<Stack space="default">
|
||
<Field label="Customer name" hint="Stored as first and last name — split on the first space">
|
||
<Input
|
||
value={form.name}
|
||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||
placeholder="Anitha Raman"
|
||
/>
|
||
</Field>
|
||
|
||
<Field label="Phone">
|
||
<Input value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} placeholder="9876543210" />
|
||
</Field>
|
||
|
||
<Field label="Email">
|
||
<Input
|
||
type="email"
|
||
value={form.email}
|
||
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||
placeholder="anitha@example.com"
|
||
/>
|
||
</Field>
|
||
</Stack>
|
||
</Modal>
|
||
</Stack>
|
||
);
|
||
}
|