Files
doormilxpress_astryx/src/pages/doormile/customers/Customers.jsx

378 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}