Ask Behavision: a panel beside any screen that talks to the head-office assistant as the signed-in user - setup questions and 'is my shop working' answered by the same thing, without leaving the app. The assistant's prompt now knows how the product is set up (installation codes, adding a camera, what a placement verdict means, the model download on first run), so it is the help and not only the analyst. A PC running on its own has nobody to ask and gets the essentials as text. Cameras and Customers were still on the pre-redesign markup - the add camera drawer ran off the right edge of the window because it used a class the new stylesheet never sized. Both are rebuilt: cameras as picture-led cards with connection and 'proven' as two separate claims and a placement check laid out as the two steps it is; the customer record as a proper sheet. mock.js renders the app in a browser with fake bindings (?mock=fresh|standalone|claimed, dev server only), so a screen can be put in front of somebody without a Windows build. It is how these were reviewed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
100 lines
3.9 KiB
JavaScript
100 lines
3.9 KiB
JavaScript
import { useState } from 'react'
|
|
import { api } from '../bridge.js'
|
|
import { usePolled, fmtDate } from '../hooks.js'
|
|
import * as Icon from '../ui/icons.jsx'
|
|
import CustomerForm from './CustomerForm.jsx'
|
|
|
|
// The customer database, and the form staff fill in when someone walks in.
|
|
export default function Customers({ session }) {
|
|
const [query, setQuery] = useState('')
|
|
const [selected, setSelected] = useState(null)
|
|
const { data, error, reload } = usePolled(
|
|
() => api.customers(query, 200), 30000, [query])
|
|
|
|
const rows = data ?? []
|
|
const named = rows.filter(r => r.has_profile).length
|
|
|
|
return (
|
|
<div className="page">
|
|
<header className="pagehead">
|
|
<div>
|
|
<h2>Customers</h2>
|
|
<p>Everyone this business has recognised. Fill in details once and they are known at every store.</p>
|
|
</div>
|
|
<label className="search">
|
|
<Icon.Search size={15} />
|
|
<input placeholder="Search by name or phone" value={query} onChange={e => setQuery(e.target.value)} />
|
|
</label>
|
|
</header>
|
|
|
|
{error && <div className="err"><Icon.Warning size={15} />{error}</div>}
|
|
|
|
<div className="metrics" style={{ marginBottom: 'var(--sp-4)' }}>
|
|
<Metric k="Known people" v={rows.length || '—'} />
|
|
<Metric k="With details" v={named || '—'} s={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
|
<Metric k="Returning" v={rows.filter(r => r.visit_count > 1).length || '—'} />
|
|
<Metric k="With consent" v={rows.filter(r => r.has_consent).length || '—'} />
|
|
</div>
|
|
|
|
<div className="panel">
|
|
{rows.length === 0
|
|
? <div className="empty tall">
|
|
<Icon.NoFaces size={34} />
|
|
<b>No customers yet</b>
|
|
<p>They appear here the first time a camera recognises them. Click one to add a name and phone number.</p>
|
|
</div>
|
|
: <div className="tablewrap">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Customer</th><th>Phone</th>
|
|
<th className="num">Visits</th>
|
|
<th>First seen</th><th>Last seen</th><th>Details</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{rows.map(c => (
|
|
<tr key={c.id} className="click" onClick={() => setSelected(c)}>
|
|
<td>
|
|
{c.full_name || <span className="note">{c.label}</span>}
|
|
{/* Only beside a name a human typed: the auto label
|
|
already IS the number ("Visitor 13"), so showing
|
|
both reads as two identifiers for one person. */}
|
|
{c.full_name && c.ref &&
|
|
<span className="note"> · {c.ref}</span>}
|
|
</td>
|
|
<td className="mono">{c.phone || '—'}</td>
|
|
<td className="num">{c.visit_count}</td>
|
|
<td>{fmtDate(c.first_seen_at)}</td>
|
|
<td>{fmtDate(c.last_seen_at)}</td>
|
|
<td>
|
|
{c.has_profile
|
|
? <span className="pill ok"><i className="dot ok" />captured</span>
|
|
: <span className="pill warn"><i className="dot warn" />needed</span>}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>}
|
|
</div>
|
|
|
|
{selected && (
|
|
<CustomerForm customer={selected} session={session}
|
|
onClose={() => setSelected(null)}
|
|
onSaved={() => { setSelected(null); reload() }} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function Metric({ k, v, s }) {
|
|
return (
|
|
<div className="metric">
|
|
<div className="metric-k">{k}</div>
|
|
<div className="metric-v">{v}</div>
|
|
{s && <div className="metric-s">{s}</div>}
|
|
</div>
|
|
)
|
|
}
|