update the adminpage ui
This commit is contained in:
189
src/features/customers/components/CustomerDialog.tsx
Normal file
189
src/features/customers/components/CustomerDialog.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import Image from 'next/image';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text, Heading} from '@astryxdesign/core/Text';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {CheckboxInput} from '@astryxdesign/core/CheckboxInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {hasRole} from '@/features/auth/services/roles';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import {useCustomerPhoto} from '@/features/customers/hooks/useCustomers';
|
||||
import {CustomerHistory} from '@/features/customers/components/CustomerHistory';
|
||||
import {RecordPurchaseDialog} from '@/features/customers/components/RecordPurchaseDialog';
|
||||
import {EraseCustomerDialog} from '@/features/customers/components/EraseCustomerDialog';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* One customer: who they are, when they came, and what staff can do about it.
|
||||
*
|
||||
* The photo is read ONCE here, not per component, because every read is a row
|
||||
* in the platform's "who looked at my customers" audit log. A missing photo is
|
||||
* the platform's own reason in plain text — most deployments store none.
|
||||
*
|
||||
* The profile save is a whole-object replace upstream, so the form holds only
|
||||
* what can be read back (name, phone, email, consent) and says what saving
|
||||
* clears. Unticking consent is a withdrawal the platform timestamps.
|
||||
*/
|
||||
export function CustomerDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
/** Something about this customer changed; the list should re-read. */
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const {user} = useSession();
|
||||
const canErase = hasRole(user?.role, 'manager');
|
||||
const photo = useCustomerPhoto(customer.id);
|
||||
|
||||
const [fullName, setFullName] = useState(customer.fullName);
|
||||
const [phone, setPhone] = useState(customer.phone);
|
||||
const [email, setEmail] = useState(customer.email);
|
||||
const [consent, setConsent] = useState(customer.hasConsent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ok: boolean; text: string} | null>(null);
|
||||
const [buying, setBuying] = useState(false);
|
||||
const [erasing, setErasing] = useState(false);
|
||||
|
||||
const hasContact = fullName.trim() !== '' || phone.trim() !== '' || email.trim() !== '';
|
||||
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
const res = await customerRepository.saveProfile(customer.id, {
|
||||
fullName,
|
||||
phone,
|
||||
email,
|
||||
consent,
|
||||
});
|
||||
setSaving(false);
|
||||
if (!res.ok) {
|
||||
setMessage({ok: false, text: res.message ?? 'Could not save these details.'});
|
||||
return;
|
||||
}
|
||||
setMessage({ok: true, text: 'Saved.'});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="info"
|
||||
width={560}
|
||||
aria-label={`Customer ${customer.label}`}
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={customer.label}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
|
||||
<HStack gap={4} vAlign="start">
|
||||
{photo.status === 'success' && photo.data.available && photo.data.url ? (
|
||||
<Image
|
||||
src={photo.data.url}
|
||||
alt={`Latest photo of ${customer.label}`}
|
||||
width={96}
|
||||
height={96}
|
||||
unoptimized
|
||||
style={{borderRadius: 8, objectFit: 'cover'}}
|
||||
/>
|
||||
) : null}
|
||||
<VStack gap={1}>
|
||||
{customer.ref ? (
|
||||
<Text size="sm" color="secondary" className="font-mono">
|
||||
{customer.ref}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text size="sm">
|
||||
{customer.visitCount} {customer.visitCount === 1 ? 'visit' : 'visits'} · last seen{' '}
|
||||
<Timestamp value={customer.lastSeenAt} format="relative" />
|
||||
</Text>
|
||||
<Text size="xsm" color="secondary">
|
||||
First seen <Timestamp value={customer.firstSeenAt} format="date" />
|
||||
</Text>
|
||||
{photo.status === 'success' && !photo.data.available && photo.data.reason ? (
|
||||
<Text size="xsm" color="secondary">
|
||||
{photo.data.reason}
|
||||
</Text>
|
||||
) : null}
|
||||
</VStack>
|
||||
</HStack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading level={4}>Details</Heading>
|
||||
<TextInput label="Name" value={fullName} onChange={setFullName} />
|
||||
<HStack gap={3} wrap="wrap">
|
||||
<TextInput label="Phone" value={phone} onChange={setPhone} isOptional />
|
||||
<TextInput label="Email" value={email} onChange={setEmail} isOptional />
|
||||
</HStack>
|
||||
<CheckboxInput
|
||||
label="They agreed to be recognised"
|
||||
value={consent}
|
||||
onChange={(v) => setConsent(v === true)}
|
||||
/>
|
||||
<Text size="xsm" color="secondary">
|
||||
Saving replaces their whole profile: any gender, date of birth or notes recorded
|
||||
elsewhere are cleared.
|
||||
</Text>
|
||||
{message ? (
|
||||
<Banner status={message.ok ? 'success' : 'error'} title={message.text} />
|
||||
) : null}
|
||||
|
||||
<Divider />
|
||||
|
||||
<Heading level={4}>Visits</Heading>
|
||||
<CustomerHistory customerId={customer.id} />
|
||||
|
||||
<HStack gap={2} hAlign="between" wrap="wrap">
|
||||
{canErase ? (
|
||||
<Button variant="destructive" onClick={() => setErasing(true)} label="Erase…" />
|
||||
) : null}
|
||||
<HStack gap={2} wrap="wrap">
|
||||
<Button variant="secondary" onClick={() => setBuying(true)} label="Record purchase" />
|
||||
<Button
|
||||
onClick={() => void save()}
|
||||
isDisabled={!hasContact || saving}
|
||||
isLoading={saving}
|
||||
label="Save details"
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{buying ? (
|
||||
<RecordPurchaseDialog
|
||||
customer={customer}
|
||||
onClose={() => setBuying(false)}
|
||||
onRecorded={() => {
|
||||
setBuying(false);
|
||||
setMessage({ok: true, text: 'Purchase recorded.'});
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{erasing ? (
|
||||
<EraseCustomerDialog
|
||||
customer={customer}
|
||||
onClose={() => setErasing(false)}
|
||||
onErased={() => {
|
||||
setErasing(false);
|
||||
onChanged();
|
||||
onClose();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
137
src/features/customers/components/CustomerDirectory.tsx
Normal file
137
src/features/customers/components/CustomerDirectory.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useState} from 'react';
|
||||
import {proportional, pixel} from '@astryxdesign/core/Table';
|
||||
import type {TableColumn} from '@astryxdesign/core/Table';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {PanelCard} from '@/shared/components/patterns/PanelCard';
|
||||
import {ResponsiveTable} from '@/shared/components/patterns/ResponsiveTable';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useCustomerSearch} from '@/features/customers/hooks/useCustomers';
|
||||
import {CustomerDialog} from '@/features/customers/components/CustomerDialog';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/** Long enough that a word typed at speed is one request, short enough to feel live. */
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Find a customer by name, phone, email or number (`42` or `V-42`).
|
||||
*
|
||||
* The term is debounced before it becomes the request: useResource keys on the
|
||||
* URL, so without it every keystroke would be a round trip to the platform.
|
||||
* With no term the list is the most recently seen — the useful default for a
|
||||
* person who has just walked in.
|
||||
*/
|
||||
export function CustomerDirectory() {
|
||||
const [input, setInput] = useState('');
|
||||
const [query, setQuery] = useState('');
|
||||
const [open, setOpen] = useState<Customer | null>(null);
|
||||
const customers = useCustomerSearch(query);
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setQuery(input), SEARCH_DEBOUNCE_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [input]);
|
||||
|
||||
const columns: TableColumn<Customer>[] = [
|
||||
{
|
||||
key: 'label',
|
||||
header: 'Customer',
|
||||
width: proportional(2),
|
||||
renderCell: (row) => (
|
||||
<VStack gap={0}>
|
||||
<Text size="sm" weight="medium">
|
||||
{row.label}
|
||||
</Text>
|
||||
<Text size="sm" color="secondary">
|
||||
{/* An unnamed customer's label already IS their number ("Visitor 42"). */}
|
||||
{[row.fullName ? row.ref : null, row.phone || null].filter(Boolean).join(' · ') || '—'}
|
||||
</Text>
|
||||
</VStack>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'visitCount',
|
||||
header: 'Visits',
|
||||
width: pixel(90),
|
||||
renderCell: (row) => (
|
||||
<Text size="sm" weight="medium">
|
||||
{row.visitCount}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastSeenAt',
|
||||
header: 'Last seen',
|
||||
width: proportional(1),
|
||||
renderCell: (row) => <Timestamp value={row.lastSeenAt} format="relative" />,
|
||||
},
|
||||
{
|
||||
key: 'hasConsent',
|
||||
header: 'Consent',
|
||||
width: pixel(110),
|
||||
renderCell: (row) =>
|
||||
row.hasConsent ? <Badge variant="success" label="Given" /> : <Text size="sm">—</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
align: 'center',
|
||||
width: pixel(100),
|
||||
renderCell: (row) => (
|
||||
<HStack hAlign="center">
|
||||
<Button size="sm" variant="secondary" onClick={() => setOpen(row)} label="Open" />
|
||||
</HStack>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<VStack gap={4}>
|
||||
<TextInput
|
||||
label="Search customers"
|
||||
isLabelHidden
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
placeholder="Name, phone, email or number (V-42)"
|
||||
hasClear
|
||||
/>
|
||||
|
||||
<PanelCard
|
||||
title={query ? `Results for “${query}”` : 'Recently seen'}
|
||||
resource={customers}
|
||||
loading={<SkeletonRows count={6} height={52} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="visitors"
|
||||
title={query ? 'No customer matches that' : 'No customers yet'}
|
||||
description={
|
||||
query
|
||||
? 'Try part of a name, a phone number, or their customer number.'
|
||||
: 'Customers appear here once a camera has recognised them.'
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(rows) => (
|
||||
<ResponsiveTable columns={columns} data={rows} idKey="id" primaryKey="label" />
|
||||
)}
|
||||
</PanelCard>
|
||||
|
||||
{open ? (
|
||||
<CustomerDialog
|
||||
key={open.id}
|
||||
customer={open}
|
||||
onClose={() => setOpen(null)}
|
||||
onChanged={customers.refetch}
|
||||
/>
|
||||
) : null}
|
||||
</VStack>
|
||||
);
|
||||
}
|
||||
49
src/features/customers/components/CustomerHistory.tsx
Normal file
49
src/features/customers/components/CustomerHistory.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {Badge} from '@astryxdesign/core/Badge';
|
||||
import {Timestamp} from '@astryxdesign/core/Timestamp';
|
||||
import {Divider} from '@astryxdesign/core/Divider';
|
||||
import {AsyncBoundary} from '@/shared/components/data/AsyncBoundary';
|
||||
import {SkeletonRows} from '@/shared/components/patterns/LoadingState';
|
||||
import {EmptyPanel} from '@/shared/components/patterns/EmptyPanel';
|
||||
import {useCustomerHistory} from '@/features/customers/hooks/useCustomers';
|
||||
|
||||
/** Their visits, newest first — the one that enrolled them is marked. */
|
||||
export function CustomerHistory({customerId}: {customerId: string}) {
|
||||
const history = useCustomerHistory(customerId);
|
||||
|
||||
return (
|
||||
<AsyncBoundary
|
||||
resource={history}
|
||||
loading={<SkeletonRows count={4} height={28} />}
|
||||
empty={
|
||||
<EmptyPanel
|
||||
icon="visitors"
|
||||
title="No visits recorded"
|
||||
description="Visits appear here once a camera recognises them."
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(visits) => (
|
||||
<VStack gap={2}>
|
||||
{visits.map((v, i) => (
|
||||
<VStack key={v.id} gap={2}>
|
||||
{i > 0 ? <Divider /> : null}
|
||||
<HStack gap={2} vAlign="center" hAlign="between">
|
||||
<VStack gap={0}>
|
||||
<Timestamp value={v.occurredAt} format="date_time" />
|
||||
<Text size="xsm" color="secondary">
|
||||
{v.siteName}
|
||||
</Text>
|
||||
</VStack>
|
||||
{v.isNewVisitor ? <Badge variant="neutral" label="First visit" /> : null}
|
||||
</HStack>
|
||||
</VStack>
|
||||
))}
|
||||
</VStack>
|
||||
)}
|
||||
</AsyncBoundary>
|
||||
);
|
||||
}
|
||||
88
src/features/customers/components/EraseCustomerDialog.tsx
Normal file
88
src/features/customers/components/EraseCustomerDialog.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Erase a customer — the right-to-be-forgotten path. Manager and above.
|
||||
*
|
||||
* Irreversible, so it asks for the customer number to be typed. What it does
|
||||
* is said plainly: the face and photo are destroyed; the visits stay as
|
||||
* anonymous footfall; the consent record stays, revoked.
|
||||
*
|
||||
* A failure is shown and never softened. The platform's 502 means the photo
|
||||
* could not be deleted and NOTHING was erased — telling the merchant otherwise
|
||||
* would leave them believing data is gone that is still there.
|
||||
*/
|
||||
export function EraseCustomerDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onErased,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
onErased: () => void;
|
||||
}) {
|
||||
const expected = customer.ref ?? customer.id;
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function submit() {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await customerRepository.erase(customer.id);
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Nothing was erased. Please try again.');
|
||||
return;
|
||||
}
|
||||
onErased();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={460}
|
||||
aria-label="Erase customer"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`Erase ${customer.label}?`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<Banner
|
||||
status="error"
|
||||
title="This cannot be undone"
|
||||
description="Their face template and photo are deleted permanently, and their name and contact details are removed. Their visits stay as anonymous footfall, and the record of their consent stays, marked withdrawn. The cameras will not re-enrol them as a new customer."
|
||||
/>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<TextInput
|
||||
label="Type the customer number to confirm"
|
||||
value={confirm}
|
||||
onChange={setConfirm}
|
||||
placeholder={expected}
|
||||
description={`Exactly: ${expected}`}
|
||||
/>
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void submit()}
|
||||
isDisabled={confirm.trim() !== expected || busy}
|
||||
isLoading={busy}
|
||||
label="Erase customer"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
126
src/features/customers/components/RecordPurchaseDialog.tsx
Normal file
126
src/features/customers/components/RecordPurchaseDialog.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {Dialog, DialogHeader} from '@astryxdesign/core/Dialog';
|
||||
import {VStack, HStack} from '@astryxdesign/core/Layout';
|
||||
import {TextInput} from '@astryxdesign/core/TextInput';
|
||||
import {NumberInput} from '@astryxdesign/core/NumberInput';
|
||||
import {Selector} from '@astryxdesign/core/Selector';
|
||||
import {Button} from '@astryxdesign/core/Button';
|
||||
import {Banner} from '@astryxdesign/core/Banner';
|
||||
import {Text} from '@astryxdesign/core/Text';
|
||||
import {useWorkspace} from '@/shared/providers/WorkspaceProvider';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Customer} from '@/features/customers/types/customer';
|
||||
|
||||
const LAST_SEEN = '__last_seen__';
|
||||
|
||||
/**
|
||||
* Link a sale to this customer, so the conversion report can say who bought.
|
||||
*
|
||||
* Lighter than the till's itemised sale on the Commerce page: an amount, and
|
||||
* optionally what they bought. Left on "where they were last seen", the
|
||||
* platform books it at that shop — what "somebody on the floor just sold them
|
||||
* something" means. Items are a comma-separated list of names, because that is
|
||||
* what the platform stores.
|
||||
*/
|
||||
export function RecordPurchaseDialog({
|
||||
customer,
|
||||
onClose,
|
||||
onRecorded,
|
||||
}: {
|
||||
customer: Customer;
|
||||
onClose: () => void;
|
||||
onRecorded: () => void;
|
||||
}) {
|
||||
const {stores} = useWorkspace();
|
||||
const [amount, setAmount] = useState<number | null>(null);
|
||||
const [site, setSite] = useState(LAST_SEEN);
|
||||
const [items, setItems] = useState('');
|
||||
const [notes, setNotes] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const shopOptions = [
|
||||
{value: LAST_SEEN, label: 'Where they were last seen'},
|
||||
...stores.filter((s) => s.id !== 'all').map((s) => ({value: s.id, label: s.name})),
|
||||
];
|
||||
|
||||
async function submit() {
|
||||
if (amount === null || amount <= 0) {
|
||||
setError('Enter the amount they paid.');
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const res = await customerRepository.recordPurchase({
|
||||
visitorId: customer.id,
|
||||
site: site === LAST_SEEN ? undefined : site,
|
||||
amount,
|
||||
currency: 'INR',
|
||||
items: items
|
||||
.split(',')
|
||||
.map((i) => i.trim())
|
||||
.filter(Boolean),
|
||||
notes: notes.trim(),
|
||||
});
|
||||
setBusy(false);
|
||||
if (!res.ok) {
|
||||
setError(res.message ?? 'Could not record that purchase.');
|
||||
return;
|
||||
}
|
||||
onRecorded();
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
purpose="required"
|
||||
width={440}
|
||||
aria-label="Record a purchase"
|
||||
>
|
||||
<VStack gap={4} width="100%">
|
||||
<DialogHeader
|
||||
title={`Record a purchase — ${customer.label}`}
|
||||
onOpenChange={(open) => (open ? undefined : onClose())}
|
||||
/>
|
||||
<NumberInput
|
||||
label="Amount (₹)"
|
||||
value={amount}
|
||||
onChange={setAmount}
|
||||
hasClear
|
||||
min={0}
|
||||
/>
|
||||
<Selector
|
||||
label="Shop"
|
||||
value={site}
|
||||
onChange={(v) => setSite(v)}
|
||||
options={shopOptions}
|
||||
/>
|
||||
<TextInput
|
||||
label="Items"
|
||||
value={items}
|
||||
onChange={setItems}
|
||||
isOptional
|
||||
placeholder="Silk saree, Blouse"
|
||||
description="Separate items with commas."
|
||||
/>
|
||||
<TextInput label="Notes" value={notes} onChange={setNotes} isOptional />
|
||||
<Text size="xsm" color="secondary">
|
||||
For an itemised bill with enquiries, use New sale on the Commerce page.
|
||||
</Text>
|
||||
{error ? <Banner status="error" title={error} /> : null}
|
||||
<HStack gap={2} hAlign="end">
|
||||
<Button variant="secondary" onClick={onClose} label="Cancel" />
|
||||
<Button
|
||||
onClick={() => void submit()}
|
||||
isDisabled={busy}
|
||||
isLoading={busy}
|
||||
label="Record purchase"
|
||||
/>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
32
src/features/customers/hooks/useCustomers.ts
Normal file
32
src/features/customers/hooks/useCustomers.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import {useResource} from '@/shared/hooks/useResource';
|
||||
import {useSession} from '@/features/auth/providers/SessionProvider';
|
||||
import {customerRepository} from '@/features/customers/repositories/customerRepository';
|
||||
import type {Resource} from '@/shared/hooks/useResource';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Customer search. An empty term lists the most recently seen — the useful
|
||||
* default for somebody who has just walked in.
|
||||
*
|
||||
* Session-gated like every other hook here, so nothing is requested from a
|
||||
* tab that has signed out.
|
||||
*/
|
||||
export function useCustomerSearch(q: string): Resource<Customer[]> {
|
||||
const {isAuthenticated} = useSession();
|
||||
return useResource(isAuthenticated ? customerRepository.search(q) : null);
|
||||
}
|
||||
|
||||
export function useCustomerHistory(id: string): Resource<CustomerVisit[]> {
|
||||
return useResource(customerRepository.history(id));
|
||||
}
|
||||
|
||||
/** A missing photo is `available: false`, never 'empty' or an error. */
|
||||
export function useCustomerPhoto(id: string): Resource<CustomerPhoto> {
|
||||
return useResource(customerRepository.photo(id), {isEmpty: () => false});
|
||||
}
|
||||
46
src/features/customers/repositories/customerRepository.ts
Normal file
46
src/features/customers/repositories/customerRepository.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
deleteJson,
|
||||
postJson,
|
||||
putJson,
|
||||
type Endpoint,
|
||||
} from '@/shared/services/httpClient';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
ProfileDraft,
|
||||
PurchaseDraft,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* TRANSPORT ONLY — customers, their history and photo, and the writes.
|
||||
*
|
||||
* The reads are Endpoints so useResource keys on the URL: a new search term or
|
||||
* a different customer refetches on its own. `id` may be the uuid or `V-42`.
|
||||
*/
|
||||
export const customerRepository = {
|
||||
search: (q: string): Endpoint<Customer[]> => ({
|
||||
path: '/api/visitors',
|
||||
params: q.trim() ? {q: q.trim()} : {},
|
||||
}),
|
||||
|
||||
history: (id: string): Endpoint<CustomerVisit[]> => ({
|
||||
path: `/api/visitors/${encodeURIComponent(id)}/history`,
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Audited upstream on every read — fetch it once per screen. */
|
||||
photo: (id: string): Endpoint<CustomerPhoto> => ({
|
||||
path: `/api/visitors/${encodeURIComponent(id)}/image`,
|
||||
params: {},
|
||||
}),
|
||||
|
||||
/** Whole-object replace; see ProfileDraft for why it is these four fields. */
|
||||
saveProfile: (id: string, draft: ProfileDraft) =>
|
||||
putJson<null>(`/api/visitors/${encodeURIComponent(id)}/profile`, draft),
|
||||
|
||||
recordPurchase: (draft: PurchaseDraft) => postJson<null>('/api/purchases', draft),
|
||||
|
||||
/** Irreversible erasure. Manager and above. */
|
||||
erase: (id: string) => deleteJson<null>(`/api/visitors/${encodeURIComponent(id)}`),
|
||||
};
|
||||
110
src/features/customers/services/mapCustomer.ts
Normal file
110
src/features/customers/services/mapCustomer.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import type {
|
||||
ApiImageRef,
|
||||
ApiProfileInput,
|
||||
ApiPurchaseInput,
|
||||
ApiVisitor,
|
||||
ApiVisitorHistoryEntry,
|
||||
} from '@/services/api/types';
|
||||
import type {
|
||||
Customer,
|
||||
CustomerPhoto,
|
||||
CustomerVisit,
|
||||
} from '@/features/customers/types/customer';
|
||||
|
||||
/**
|
||||
* Platform shapes → what the customer screens consume, and request bodies →
|
||||
* what the platform accepts.
|
||||
*
|
||||
* Kept out of the route files so the list, the history, the save and the
|
||||
* purchase routes agree on one mapping, the way `mapTeam` does for the team.
|
||||
*/
|
||||
export function toCustomer(v: ApiVisitor): Customer {
|
||||
return {
|
||||
id: v.id,
|
||||
ref: v.ref || null,
|
||||
// Upstream `label` is the name given at ENROLMENT ("Visitor 42") and is
|
||||
// never changed by naming them — the chosen name is `full_name`. So the
|
||||
// name wins when there is one, as it does in the platform's own console.
|
||||
label: v.full_name || v.label || v.ref || 'Customer',
|
||||
fullName: v.full_name ?? '',
|
||||
phone: v.phone ?? '',
|
||||
email: v.email ?? '',
|
||||
visitCount: v.visit_count ?? 0,
|
||||
firstSeenAt: v.first_seen_at,
|
||||
lastSeenAt: v.last_seen_at,
|
||||
hasProfile: v.has_profile === true,
|
||||
hasConsent: v.has_consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function toCustomerVisit(h: ApiVisitorHistoryEntry): CustomerVisit {
|
||||
return {
|
||||
id: h.id,
|
||||
occurredAt: h.occurred_at,
|
||||
siteName: h.site,
|
||||
cameraId: h.camera_id,
|
||||
isNewVisitor: h.is_new_visitor === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A relative URL is served by the platform and needs the session's token,
|
||||
* which an <img> cannot send — so it goes through /api/faces. A presigned
|
||||
* object-storage link carries its own signature and is used as it is.
|
||||
*/
|
||||
export function toCustomerPhoto(img: ApiImageRef): CustomerPhoto {
|
||||
if (!img.available || !img.url) {
|
||||
return {available: false, url: null, reason: img.reason ?? null};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
url: img.url.startsWith('http')
|
||||
? img.url
|
||||
: `/api/faces?src=${encodeURIComponent(img.url)}`,
|
||||
reason: null,
|
||||
};
|
||||
}
|
||||
|
||||
function text(v: unknown): string {
|
||||
return typeof v === 'string' ? v.trim() : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The save is a WHOLE-OBJECT replace upstream. Gender, date of birth and notes
|
||||
* are sent empty because this console cannot read them back to preserve them —
|
||||
* see ProfileDraft — and the form says so before anybody saves.
|
||||
*/
|
||||
export function toProfileInput(body: Record<string, unknown>): ApiProfileInput {
|
||||
return {
|
||||
full_name: text(body.fullName),
|
||||
phone: text(body.phone),
|
||||
email: text(body.email),
|
||||
gender: '',
|
||||
date_of_birth: '',
|
||||
notes: '',
|
||||
consent: body.consent === true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A request body → the platform's `PurchaseInput`.
|
||||
*
|
||||
* `items` upstream is a list of item NAMES; anything that is not a non-empty
|
||||
* string is dropped rather than sent, because a number there fails the
|
||||
* platform's JSON decode for the whole request.
|
||||
*/
|
||||
export function toPurchaseInput(body: Record<string, unknown>): ApiPurchaseInput {
|
||||
const items = Array.isArray(body.items)
|
||||
? body.items.map(text).filter((i) => i !== '')
|
||||
: [];
|
||||
const site = text(body.site) || text(body.siteId);
|
||||
return {
|
||||
visitor_id: text(body.visitorId),
|
||||
site_id: site || undefined,
|
||||
amount: Number(body.amount),
|
||||
currency: text(body.currency).toUpperCase() || 'INR',
|
||||
items: items.length > 0 ? items : undefined,
|
||||
source: 'console',
|
||||
notes: text(body.notes) || undefined,
|
||||
};
|
||||
}
|
||||
74
src/features/customers/types/customer.ts
Normal file
74
src/features/customers/types/customer.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* A customer, as the console consumes it — `GET /api/visitors`.
|
||||
*
|
||||
* `label` is what to show: it reads "Visitor 42" until somebody names them,
|
||||
* then the name. `fullName` stays empty until then, which is how the screen
|
||||
* tells an unnamed regular from a named one without guessing from the label.
|
||||
*
|
||||
* `ref` ("V-42") is per tenant and immutable — safe to show, to search by and
|
||||
* to type into a till. It is null only on a deployment that predates numbering.
|
||||
*/
|
||||
export interface Customer extends Record<string, unknown> {
|
||||
id: string;
|
||||
ref: string | null;
|
||||
label: string;
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
visitCount: number;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
hasProfile: boolean;
|
||||
/** Recorded agreement to be recognised. Withdrawn, never deleted, upstream. */
|
||||
hasConsent: boolean;
|
||||
}
|
||||
|
||||
/** One visit in a customer's history. Newest first. */
|
||||
export interface CustomerVisit {
|
||||
id: string;
|
||||
occurredAt: string;
|
||||
/** The shop's display name at the time of reading, not its slug. */
|
||||
siteName: string;
|
||||
cameraId: string;
|
||||
/** True on exactly one row — the visit that enrolled them. */
|
||||
isNewVisitor: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The customer's latest photo.
|
||||
*
|
||||
* `available: false` is DATA, not an error: images are off by default across
|
||||
* the product, so most deployments legitimately have none, and `reason` says
|
||||
* why. `url` is already proxied — a component never builds a platform URL.
|
||||
*/
|
||||
export interface CustomerPhoto {
|
||||
available: boolean;
|
||||
url: string | null;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the profile form edits.
|
||||
*
|
||||
* Deliberately only what the platform lets this console READ back. The save is
|
||||
* a whole-object replace, and gender, date of birth and notes are not on any
|
||||
* read endpoint — a form offering them would show them blank and could only
|
||||
* ever overwrite what somebody else had recorded.
|
||||
*/
|
||||
export interface ProfileDraft {
|
||||
fullName: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
consent: boolean;
|
||||
}
|
||||
|
||||
/** A sale linked to a customer, so the conversion report can say who bought. */
|
||||
export interface PurchaseDraft {
|
||||
visitorId: string;
|
||||
/** Slug or uuid. Omitted = where this customer was last seen. */
|
||||
site?: string;
|
||||
amount: number;
|
||||
currency: string;
|
||||
items: string[];
|
||||
notes: string;
|
||||
}
|
||||
Reference in New Issue
Block a user