updates on the ui changes and changed into doormile
This commit is contained in:
270
src/pages/doormile/customers/Customers.jsx
Normal file
270
src/pages/doormile/customers/Customers.jsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { 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, PageHeader, Stack,
|
||||
} from '@/components/ds';
|
||||
import { ListToolbar } from '@/components/doormile/ListToolbar';
|
||||
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 || '';
|
||||
/** Tenant customers carry no booking count; 0 is honest rather than blank. */
|
||||
const custBookings = (row) => row.totalbookings ?? 0;
|
||||
|
||||
export default function Customers() {
|
||||
const navigate = useNavigate();
|
||||
const { data: customers = [], isLoading, isFetching } = useTenantCustomers();
|
||||
const updateCustomer = useUpdateTenantCustomer();
|
||||
|
||||
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 = customers.length;
|
||||
return {
|
||||
total,
|
||||
withEmail: customers.filter((row) => Boolean(row.email)).length,
|
||||
withPhone: customers.filter((row) => Boolean(row.phone)).length,
|
||||
totalBookings: customers.reduce((sum, row) => sum + (Number(row.totalbookings) || 0), 0),
|
||||
};
|
||||
}, [customers]);
|
||||
|
||||
const rows = useMemo(() => {
|
||||
let list = customers;
|
||||
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], debouncedSearch));
|
||||
}, [customers, 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 = () => {
|
||||
/* 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] = String(form.name || '').trim().split(/\s+/);
|
||||
updateCustomer.mutate(
|
||||
{
|
||||
id: custId(editRow),
|
||||
data: {
|
||||
firstname: firstname || '',
|
||||
lastname: rest.join(' '),
|
||||
phone: form.phone || '',
|
||||
email: form.email || '',
|
||||
},
|
||||
},
|
||||
{ onSuccess: (res) => res?.success !== false && setEditRow(null) }
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'customer',
|
||||
header: 'Customer',
|
||||
sortable: true,
|
||||
accessor: custName,
|
||||
cell: (row) => (
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<Avatar name={custName(row)} size="sm" />
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body-sm font-semibold text-ink-1">{custName(row)}</p>
|
||||
<p className="text-caption text-ink-3">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">
|
||||
<PageHeader
|
||||
icon={Users}
|
||||
title="Customers"
|
||||
subtitle="The people at the receiving end of every booking"
|
||||
actions={
|
||||
<Button onClick={() => navigate('/doormile/customer/create')}>
|
||||
<Plus className="mr-1.5 h-4 w-4" /> New customer
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
|
||||
<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 or email"
|
||||
count={{ shown: rows.length, total: customers.length, noun: 'customers' }}
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user