Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
1
desktop/frontend/dist/assets/index-XjqO50wd.css
vendored
Normal file
1
desktop/frontend/dist/assets/index-XjqO50wd.css
vendored
Normal file
File diff suppressed because one or more lines are too long
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
Normal file
40
desktop/frontend/dist/assets/index-whFsTNQf.js
vendored
Normal file
File diff suppressed because one or more lines are too long
13
desktop/frontend/dist/index.html
vendored
Normal file
13
desktop/frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
<script type="module" crossorigin src="./assets/index-whFsTNQf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-XjqO50wd.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
12
desktop/frontend/index.html
Normal file
12
desktop/frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Behavision</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
1738
desktop/frontend/package-lock.json
generated
Normal file
1738
desktop/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
desktop/frontend/package.json
Normal file
18
desktop/frontend/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "behavision-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"vite": "^5.4.8"
|
||||
}
|
||||
}
|
||||
161
desktop/frontend/src/App.jsx
Normal file
161
desktop/frontend/src/App.jsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { api, isDesktop, message } from './bridge.js'
|
||||
import { usePolled } from './hooks.js'
|
||||
import Login from './views/Login.jsx'
|
||||
import Setup from './views/Setup.jsx'
|
||||
import Live from './views/Live.jsx'
|
||||
import Customers from './views/Customers.jsx'
|
||||
import Cameras from './views/Cameras.jsx'
|
||||
|
||||
// Three screens, and the trim is by AUDIENCE rather than by taste.
|
||||
//
|
||||
// This window runs on a PC behind a counter, and the person in front of it can
|
||||
// act on exactly three things: is it working, who is this customer, and is the
|
||||
// camera set up. Footfall and Sales answer a different person's questions - an
|
||||
// owner comparing shops, who is not standing in one - and they now live on the
|
||||
// head-office platform where a comparison across sites is even possible. A
|
||||
// month-on-month chart on a shop PC was a report nobody there could act on,
|
||||
// competing for the attention of somebody with a customer waiting.
|
||||
//
|
||||
// `cloud` marks a screen that cannot work without head office. A PC set up on
|
||||
// its own hides those rather than showing a screen that can only ever fail:
|
||||
// the customer record lives on the server, the cameras and what this PC is
|
||||
// seeing do not.
|
||||
const VIEWS = [
|
||||
{ id: 'live', label: 'Live', glyph: '◉', View: Live },
|
||||
{ id: 'customers', label: 'Customers', glyph: '☺', View: Customers, cloud: true },
|
||||
{ id: 'cameras', label: 'Cameras', glyph: '▢', View: Cameras },
|
||||
]
|
||||
|
||||
export default function App() {
|
||||
const [session, setSession] = useState(null)
|
||||
const [view, setView] = useState('live')
|
||||
const [booting, setBooting] = useState(true)
|
||||
// A standalone PC can join head office later. That is the same Setup screen,
|
||||
// reached deliberately rather than because the app will not open otherwise.
|
||||
const [linking, setLinking] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try { setSession(await api.session()) } catch { setSession(null) }
|
||||
setBooting(false)
|
||||
})()
|
||||
}, [])
|
||||
|
||||
if (!isDesktop()) {
|
||||
// The frontend can be served by `npm run dev` for styling work, where the
|
||||
// Go bindings do not exist. Saying so beats a blank screen and a console
|
||||
// error nobody will read.
|
||||
return (
|
||||
<div className="login"><div className="box">
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">
|
||||
This is the Behavision window running outside the app, so it has no
|
||||
connection to the recognition engine. Launch the Behavision
|
||||
application instead.
|
||||
</p>
|
||||
</div></div>
|
||||
)
|
||||
}
|
||||
|
||||
if (booting) return <div className="login"><p className="note">Starting…</p></div>
|
||||
// Which shop this PC IS comes before who is standing at it. An installer on a
|
||||
// brand new counter has a code and often no account yet, and every screen
|
||||
// behind here is about a shop this PC does not have one of. Unless there is
|
||||
// no head office at all, which is the other supported answer.
|
||||
if (!session?.claimed && !session?.standalone) return <Setup onDone={setSession} />
|
||||
if (!session?.standalone && !session?.logged_in) return <Login onDone={setSession} />
|
||||
|
||||
const views = VIEWS.filter(v => !v.cloud || !session.standalone)
|
||||
const Current = views.find(v => v.id === view)?.View ?? Live
|
||||
|
||||
if (linking) {
|
||||
return <Setup onDone={s => { setLinking(false); setSession(s) }}
|
||||
onCancel={() => setLinking(false)} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shell">
|
||||
<aside className="side">
|
||||
<div className="brand">
|
||||
<h1>Behavision</h1>
|
||||
<p>{session.site_name || session.user?.client_name || 'Store'}</p>
|
||||
</div>
|
||||
<nav className="nav">
|
||||
{views.map(v => (
|
||||
<button key={v.id} onClick={() => setView(v.id)}
|
||||
aria-current={v.id === view ? 'page' : undefined}>
|
||||
<span className="glyph">{v.glyph}</span>{v.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
<EngineBox />
|
||||
<div style={{ padding: '10px 12px 14px', borderTop: '1px solid var(--line-soft)' }}>
|
||||
{session.standalone
|
||||
? <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
Running on its own
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
onClick={() => setLinking(true)}>
|
||||
Link to head office
|
||||
</button>
|
||||
</>
|
||||
: <>
|
||||
<div className="note" style={{ marginBottom: 8 }}>
|
||||
{session.user?.email}
|
||||
</div>
|
||||
<button className="btn sm" style={{ width: '100%' }}
|
||||
onClick={async () => setSession(await api.logout())}>
|
||||
Sign out
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="main"><Current session={session} /></main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Always visible, because "is recognition actually running" is the question
|
||||
// behind every other screen — an empty Live page means something completely
|
||||
// different depending on the answer.
|
||||
function EngineBox() {
|
||||
const { data, reload } = usePolled(() => api.engineStatus(), 5000)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const s = data ?? { state: 'stopped' }
|
||||
|
||||
const act = useCallback(async fn => {
|
||||
setBusy(true)
|
||||
try { await fn() } catch (e) { alert(message(e)) }
|
||||
finally { setBusy(false); reload() }
|
||||
}, [reload])
|
||||
|
||||
const running = s.state === 'running'
|
||||
const cams = Object.values(s.cameras ?? {})
|
||||
const up = cams.filter(Boolean).length
|
||||
|
||||
let tone = 'idle', text = 'Stopped'
|
||||
if (s.state === 'failed' || s.state === 'backoff') { tone = 'bad'; text = 'Not running' }
|
||||
else if (running && !s.reachable) { tone = 'warn'; text = 'Starting…' }
|
||||
else if (running && cams.length === 0) { tone = 'warn'; text = 'No cameras' }
|
||||
else if (running && up === 0) { tone = 'bad'; text = 'No camera connected' }
|
||||
else if (running && up < cams.length) { tone = 'warn'; text = `${up} of ${cams.length} cameras` }
|
||||
else if (running) { tone = 'ok'; text = `Watching ${up} camera${up === 1 ? '' : 's'}` }
|
||||
|
||||
return (
|
||||
<div className="enginebox">
|
||||
<div className="row"><i className={`dot ${tone}`} /><strong>{text}</strong></div>
|
||||
{s.recognition_model && (
|
||||
<span className="label">Model: {s.recognition_model}</span>
|
||||
)}
|
||||
{s.error && <span className="label" style={{ color: 'var(--bad)' }}>{s.error}</span>}
|
||||
<div className="actions">
|
||||
<button className="btn sm" disabled={busy || running}
|
||||
onClick={() => act(api.startEngine)}>Start</button>
|
||||
<button className="btn sm" disabled={busy || !running}
|
||||
onClick={() => act(api.stopEngine)}>Stop</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
64
desktop/frontend/src/bridge.js
Normal file
64
desktop/frontend/src/bridge.js
Normal file
@@ -0,0 +1,64 @@
|
||||
// The single seam between React and Go.
|
||||
//
|
||||
// Wails injects bound methods at window.go.main.App.*. Calling them through
|
||||
// here rather than importing generated bindings means `npm run build` works
|
||||
// without running `wails generate`, and it gives one place to handle the
|
||||
// "engine not running yet" case that every screen has to survive.
|
||||
const app = () => window?.go?.main?.App
|
||||
|
||||
export const isDesktop = () => Boolean(app())
|
||||
|
||||
async function call(name, ...args) {
|
||||
const a = app()
|
||||
if (!a || typeof a[name] !== 'function') {
|
||||
throw new Error(`${name} is unavailable — run this inside the Behavision app`)
|
||||
}
|
||||
return a[name](...args)
|
||||
}
|
||||
|
||||
// Every binding the UI uses, named as the UI thinks of them.
|
||||
export const api = {
|
||||
session: () => call('Session'),
|
||||
login: (email, password) => call('Login', email, password),
|
||||
logout: () => call('Logout'),
|
||||
// The one-shot installation code that links this PC to a shop.
|
||||
claim: (code) => call('Claim', code),
|
||||
// Set this PC up on its own, with no head office at all.
|
||||
runStandalone: () => call('RunStandalone'),
|
||||
|
||||
engineStatus: () => call('EngineStatus'),
|
||||
startEngine: () => call('StartEngine'),
|
||||
stopEngine: () => call('StopEngine'),
|
||||
|
||||
cameras: () => call('Cameras'),
|
||||
testCamera: (cam) => call('TestCamera', cam),
|
||||
saveCamera: (id, cam) => call('SaveCamera', id, cam),
|
||||
deleteCamera: (id) => call('DeleteCamera', id),
|
||||
startPlacement: (id, seconds) => call('StartPlacementCheck', id, seconds),
|
||||
placementResult: (id) => call('PlacementResult', id),
|
||||
streamURL: (id) => call('StreamURL', id),
|
||||
|
||||
live: () => call('Live'),
|
||||
pipelineStatus: () => call('PipelineStatus'),
|
||||
localIdentities: (n) => call('LocalIdentities', n),
|
||||
localSightings: (n) => call('LocalSightings', n),
|
||||
|
||||
footfall: (from, to, bucket) => call('Footfall', from, to, bucket),
|
||||
sites: () => call('Sites'),
|
||||
visitorHistory: (id, limit) => call('VisitorHistory', id, limit),
|
||||
visitorPhoto: (id) => call('VisitorPhoto', id),
|
||||
forgetCustomer: (id) => call('ForgetCustomer', id),
|
||||
sales: (from, to) => call('Sales', from, to),
|
||||
customers: (q, limit) => call('Customers', q, limit),
|
||||
saveProfile: (p) => call('SaveProfile', p),
|
||||
recordPurchase: (id, amount, items, notes) =>
|
||||
call('RecordPurchase', id, amount, items, notes),
|
||||
}
|
||||
|
||||
// Errors from Go arrive as strings or Error objects depending on the path.
|
||||
// Normalising here keeps every catch block in the UI to one line.
|
||||
export function message(err) {
|
||||
if (!err) return 'Something went wrong.'
|
||||
if (typeof err === 'string') return err
|
||||
return err.message || String(err)
|
||||
}
|
||||
65
desktop/frontend/src/hooks.js
Normal file
65
desktop/frontend/src/hooks.js
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { message } from './bridge.js'
|
||||
|
||||
// One hook for every screen that loads and refreshes.
|
||||
//
|
||||
// It exists because the naive version has two bugs every screen would repeat:
|
||||
// a slow response arriving after the user navigated away sets state on an
|
||||
// unmounted component, and a poll that fires while the previous request is
|
||||
// still running stacks up requests against an engine that is already slow.
|
||||
export function usePolled(fn, intervalMs, deps = []) {
|
||||
const [data, setData] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const alive = useRef(true)
|
||||
const busy = useRef(false)
|
||||
|
||||
const run = useCallback(async () => {
|
||||
if (busy.current) return
|
||||
busy.current = true
|
||||
try {
|
||||
const result = await fn()
|
||||
if (!alive.current) return
|
||||
setData(result)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
if (alive.current) setError(message(err))
|
||||
} finally {
|
||||
busy.current = false
|
||||
if (alive.current) setLoading(false)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, deps)
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true
|
||||
run()
|
||||
if (!intervalMs) return () => { alive.current = false }
|
||||
const id = setInterval(run, intervalMs)
|
||||
return () => { alive.current = false; clearInterval(id) }
|
||||
}, [run, intervalMs])
|
||||
|
||||
return { data, error, loading, reload: run }
|
||||
}
|
||||
|
||||
export function fmtTime(ts) {
|
||||
if (!ts) return '—'
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
|
||||
if (isNaN(d)) return '—'
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
export function fmtDate(ts) {
|
||||
if (!ts) return '—'
|
||||
const d = typeof ts === 'number' ? new Date(ts * 1000) : new Date(ts)
|
||||
if (isNaN(d)) return '—'
|
||||
return d.toLocaleDateString([], { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
export function daysAgo(n) {
|
||||
const d = new Date()
|
||||
d.setDate(d.getDate() - n)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
export function today() { return new Date().toISOString().slice(0, 10) }
|
||||
8
desktop/frontend/src/main.jsx
Normal file
8
desktop/frontend/src/main.jsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import App from './App.jsx'
|
||||
import './styles.css'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode><App /></React.StrictMode>
|
||||
)
|
||||
252
desktop/frontend/src/styles.css
Normal file
252
desktop/frontend/src/styles.css
Normal file
@@ -0,0 +1,252 @@
|
||||
/* Behavision desktop — an instrument panel, not a website.
|
||||
A shop PC runs this all day on a cheap monitor, so: high contrast, dense
|
||||
but not cramped, and state readable at a glance from across a counter. */
|
||||
|
||||
:root {
|
||||
--ground: #0E1317;
|
||||
--surface: #161D23;
|
||||
--surface-2: #1D262D;
|
||||
--line: #27333B;
|
||||
--line-soft: #1F2A31;
|
||||
--ink: #E7EEF3;
|
||||
--ink-2: #B4C2CC;
|
||||
--muted: #7C8B97;
|
||||
--accent: #45B0C7;
|
||||
--accent-dim:#123039;
|
||||
--ok: #4FB37B;
|
||||
--warn: #E0A33A;
|
||||
--bad: #E0655A;
|
||||
--radius: 8px;
|
||||
--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; }
|
||||
html, body, #root { height: 100%; }
|
||||
body {
|
||||
background: var(--ground);
|
||||
color: var(--ink);
|
||||
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
|
||||
/* ---------------------------------------------------------------- shell -- */
|
||||
.shell { display: grid; grid-template-columns: 216px 1fr; height: 100%; }
|
||||
.side {
|
||||
background: var(--surface); border-right: 1px solid var(--line);
|
||||
display: flex; flex-direction: column; min-height: 0;
|
||||
}
|
||||
.side .brand {
|
||||
padding: 18px 18px 14px; border-bottom: 1px solid var(--line-soft);
|
||||
}
|
||||
.side .brand h1 { font-size: 15px; font-weight: 650; letter-spacing: -.01em; }
|
||||
.side .brand p { font-size: 11.5px; color: var(--muted); margin-top: 3px; }
|
||||
.nav { padding: 10px 10px; display: flex; flex-direction: column; gap: 2px; flex: 1; }
|
||||
.nav button {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%;
|
||||
background: none; border: 0; border-radius: 6px; padding: 8px 10px;
|
||||
color: var(--ink-2); cursor: pointer; text-align: left; font-size: 13.5px;
|
||||
}
|
||||
.nav button:hover { background: var(--surface-2); color: var(--ink); }
|
||||
.nav button[aria-current="page"] { background: var(--accent-dim); color: var(--accent); font-weight: 550; }
|
||||
.nav .glyph { width: 16px; text-align: center; opacity: .85; font-size: 13px; }
|
||||
|
||||
.enginebox { padding: 12px; border-top: 1px solid var(--line-soft); }
|
||||
.enginebox .row { display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.enginebox .label { color: var(--muted); font-size: 11px; margin-top: 2px;
|
||||
display: block; line-height: 1.4; }
|
||||
.enginebox .actions { display: flex; gap: 6px; margin-top: 10px; }
|
||||
|
||||
.main { min-width: 0; min-height: 0; overflow-y: auto; }
|
||||
.page { padding: 22px 26px 40px; max-width: 1180px; }
|
||||
.page > header { margin-bottom: 18px; }
|
||||
.page h2 { font-size: 19px; font-weight: 620; letter-spacing: -.01em; }
|
||||
.page header p { color: var(--muted); font-size: 13px; margin-top: 3px; }
|
||||
|
||||
/* --------------------------------------------------------------- pieces -- */
|
||||
.card {
|
||||
background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: var(--radius); padding: 16px;
|
||||
}
|
||||
.card h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); font-weight: 600; margin-bottom: 12px; }
|
||||
.grid { display: grid; gap: 14px; }
|
||||
.cols-4 { grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); }
|
||||
.cols-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }
|
||||
|
||||
.stat .value { font-size: 30px; font-weight: 620; letter-spacing: -.02em;
|
||||
font-variant-numeric: tabular-nums; line-height: 1.1; }
|
||||
.stat .unit { font-size: 15px; color: var(--muted); margin-left: 3px; }
|
||||
.stat .sub { color: var(--muted); font-size: 12px; margin-top: 5px; }
|
||||
|
||||
.dot { width: 8px; height: 8px; border-radius: 50%; flex: none; }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.warn { background: var(--warn); }
|
||||
.dot.bad { background: var(--bad); }
|
||||
.dot.idle { background: var(--muted); }
|
||||
|
||||
.pill { display: inline-flex; align-items: center; gap: 5px; font-size: 11px;
|
||||
padding: 3px 8px; border-radius: 99px; border: 1px solid var(--line);
|
||||
color: var(--muted); white-space: nowrap; }
|
||||
.pill.ok { color: var(--ok); border-color: #2b5c42; background: #12251b; }
|
||||
.pill.warn { color: var(--warn); border-color: #5c4a22; background: #241d0f; }
|
||||
.pill.bad { color: var(--bad); border-color: #5c2e2a; background: #241312; }
|
||||
|
||||
.btn {
|
||||
background: var(--surface-2); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 7px 13px; cursor: pointer; font-size: 13px;
|
||||
color: var(--ink); white-space: nowrap;
|
||||
}
|
||||
.btn:hover:not(:disabled) { background: #26323a; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn.primary { background: var(--accent); border-color: var(--accent); color: #06222a;
|
||||
font-weight: 600; }
|
||||
.btn.primary:hover:not(:disabled) { background: #5ac0d6; }
|
||||
.btn.danger { color: var(--bad); border-color: #4a2823; }
|
||||
.btn.sm { padding: 4px 9px; font-size: 12px; }
|
||||
|
||||
.field { display: block; margin-bottom: 12px; }
|
||||
.field span { display: block; font-size: 11.5px; color: var(--muted);
|
||||
margin-bottom: 4px; letter-spacing: .01em; }
|
||||
.field input, .field select, .field textarea {
|
||||
width: 100%; background: var(--ground); border: 1px solid var(--line);
|
||||
border-radius: 6px; padding: 8px 10px; font-size: 13.5px;
|
||||
user-select: text;
|
||||
}
|
||||
.field input:focus, .field select:focus, .field textarea:focus {
|
||||
border-color: var(--accent); outline: none;
|
||||
}
|
||||
.field textarea { resize: vertical; min-height: 66px; }
|
||||
.fieldrow { display: grid; gap: 0 12px; grid-template-columns: 1fr 1fr; }
|
||||
|
||||
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
th { text-align: left; font-size: 10.5px; text-transform: uppercase;
|
||||
letter-spacing: .08em; color: var(--muted); font-weight: 600;
|
||||
padding: 8px 10px; border-bottom: 1px solid var(--line); }
|
||||
td { padding: 9px 10px; border-bottom: 1px solid var(--line-soft); vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr.click { cursor: pointer; }
|
||||
tbody tr.click:hover { background: var(--surface-2); }
|
||||
td.num { font-variant-numeric: tabular-nums; text-align: right; }
|
||||
.tablewrap { overflow-x: auto; }
|
||||
|
||||
.empty { color: var(--muted); font-size: 13px; padding: 26px 4px; text-align: center; }
|
||||
.err {
|
||||
border: 1px solid #5c2e2a; background: #241312; color: #f0b3ad;
|
||||
border-radius: 6px; padding: 10px 12px; font-size: 13px; margin-bottom: 14px;
|
||||
}
|
||||
.note { color: var(--muted); font-size: 12.5px; }
|
||||
.mono { font-family: var(--mono); font-size: 12px; }
|
||||
|
||||
/* --------------------------------------------------------------- login --- */
|
||||
.login { height: 100%; display: grid; place-items: center; padding: 24px; }
|
||||
.login .box { width: 100%; max-width: 380px; }
|
||||
.login h1 { font-size: 21px; font-weight: 650; letter-spacing: -.015em; }
|
||||
.login .lead { color: var(--muted); font-size: 13px; margin: 6px 0 22px; }
|
||||
.login form { background: var(--surface); border: 1px solid var(--line);
|
||||
border-radius: 10px; padding: 20px; }
|
||||
.login .btn { width: 100%; margin-top: 6px; }
|
||||
.login .foot { color: var(--muted); font-size: 11.5px; margin-top: 14px;
|
||||
text-align: center; line-height: 1.5; }
|
||||
/* The second way out of the setup screen: a shop with no head office. Styled
|
||||
quieter than the form above it because linking is still the common case,
|
||||
but present, because for a single-till shop it is the only one that works. */
|
||||
.login .alt { margin-top: 18px; padding-top: 16px; text-align: center;
|
||||
border-top: 1px solid var(--line-soft); }
|
||||
.login .alt .note { line-height: 1.55; margin-bottom: 12px; text-align: left; }
|
||||
.login .alt .btn { margin-top: 0; }
|
||||
.linkbtn { background: none; border: 0; padding: 0; cursor: pointer;
|
||||
font: inherit; font-size: 12.5px; color: var(--accent);
|
||||
text-decoration: underline; text-underline-offset: 3px; }
|
||||
.linkbtn:hover { color: var(--ink); }
|
||||
|
||||
/* ---------------------------------------------------------------- live --- */
|
||||
.feeds { display: grid; gap: 14px; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); }
|
||||
.feed { background: #000; border: 1px solid var(--line); border-radius: var(--radius);
|
||||
overflow: hidden; }
|
||||
.feed img { width: 100%; display: block; aspect-ratio: 16/9; object-fit: cover; background: #000; }
|
||||
.feed .cap { display: flex; justify-content: space-between; align-items: center;
|
||||
padding: 8px 11px; background: var(--surface); font-size: 12.5px; }
|
||||
|
||||
.events { list-style: none; max-height: 420px; overflow-y: auto; }
|
||||
.events li { display: flex; gap: 9px; align-items: baseline;
|
||||
padding: 7px 2px; border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.events li:last-child { border-bottom: 0; }
|
||||
.events .when { color: var(--muted); font-family: var(--mono); font-size: 11px;
|
||||
flex: none; }
|
||||
.tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; flex: none;
|
||||
background: var(--surface-2); color: var(--muted); }
|
||||
.tag.new { background: #17364f; color: #86c2ec; }
|
||||
.tag.seen { background: #14301f; color: #7fcb9c; }
|
||||
.tag.miss { background: #3a1c1a; color: #eb9a92; }
|
||||
|
||||
/* -------------------------------------------------------------- charts --- */
|
||||
.bars { display: flex; align-items: flex-end; gap: 3px; height: 150px; margin-top: 4px; }
|
||||
.bars .col { flex: 1; display: flex; flex-direction: column; justify-content: flex-end;
|
||||
gap: 2px; min-width: 0; }
|
||||
.bars .seg { border-radius: 2px 2px 0 0; }
|
||||
.bars .seg.ret { background: var(--accent); }
|
||||
.bars .seg.new { background: #2f6f81; }
|
||||
.axis { display: flex; justify-content: space-between; color: var(--muted);
|
||||
font-size: 10.5px; margin-top: 6px; font-family: var(--mono); }
|
||||
.key { display: flex; gap: 14px; font-size: 11.5px; color: var(--muted); margin-top: 10px; }
|
||||
.key i { display: inline-block; width: 9px; height: 9px; border-radius: 2px;
|
||||
margin-right: 5px; vertical-align: -1px; }
|
||||
|
||||
/* --------------------------------------------------------------- drawer -- */
|
||||
.drawer { position: fixed; inset: 0; background: rgba(4,8,10,.6);
|
||||
display: flex; justify-content: flex-end; z-index: 30; }
|
||||
.drawer .panel { width: min(480px, 100%); height: 100%; background: var(--surface);
|
||||
border-left: 1px solid var(--line); overflow-y: auto; padding: 20px 22px 40px; }
|
||||
.drawer h3 { font-size: 16px; font-weight: 620; text-transform: none;
|
||||
letter-spacing: -.01em; color: var(--ink); margin-bottom: 2px; }
|
||||
/* Close lives in the sticky header (.who) now. Positioned against the fixed
|
||||
overlay it stayed put while the sheet scrolled underneath it, printing the
|
||||
button on top of whatever happened to be at the top of the viewport. */
|
||||
|
||||
/* -- customer record ---------------------------------------------------- */
|
||||
/* Full-bleed sticky header: a customer record is long enough to scroll, and
|
||||
both the name and the way out have to stay reachable. The negative margins
|
||||
cancel the panel's padding so the background covers the full width. */
|
||||
.who { position: sticky; top: -20px; z-index: 1; display: flex; gap: 14px;
|
||||
align-items: flex-start; background: var(--surface);
|
||||
margin: -20px -22px 18px; padding: 20px 22px 14px;
|
||||
border-bottom: 1px solid var(--line-soft); }
|
||||
.who .grow { flex: 1; min-width: 0; }
|
||||
.who h3 { margin-bottom: 2px; }
|
||||
.avatar { width: 64px; height: 64px; border-radius: 10px; flex: none;
|
||||
object-fit: cover; background: var(--ground);
|
||||
border: 1px solid var(--line); }
|
||||
.avatar.none { display: grid; place-items: center; color: var(--muted);
|
||||
font-size: 20px; font-weight: 600; letter-spacing: .02em; }
|
||||
|
||||
.timeline { list-style: none; max-height: 220px; overflow-y: auto; }
|
||||
.timeline li { display: flex; gap: 10px; align-items: baseline; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--line-soft); font-size: 12.5px; }
|
||||
.timeline li:last-child { border-bottom: 0; }
|
||||
.timeline .when { font-family: var(--mono); font-size: 11px; color: var(--muted);
|
||||
flex: none; min-width: 108px; }
|
||||
.timeline .where { flex: 1; min-width: 0; overflow: hidden;
|
||||
text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* Visually separated from Save: this is the one control in the sheet that
|
||||
cannot be undone, and it must not read as just another button in a row. */
|
||||
.danger-zone { margin-top: 22px; border-color: #4a2823; }
|
||||
.danger-zone > h3 { color: var(--bad); }
|
||||
.danger-zone .note { margin-bottom: 10px; }
|
||||
|
||||
.confirm h4 { font-size: 13.5px; font-weight: 620; margin-bottom: 10px; }
|
||||
.confirm .cols { display: grid; grid-template-columns: 1fr 1fr; gap: 14px;
|
||||
margin-bottom: 12px; }
|
||||
@media (max-width: 560px) { .confirm .cols { grid-template-columns: 1fr; } }
|
||||
.confirm .lbl { font-size: 11px; text-transform: uppercase; letter-spacing: .07em;
|
||||
color: var(--muted); margin-bottom: 5px; }
|
||||
.confirm .lbl.bad { color: var(--bad); }
|
||||
.confirm ul { list-style: none; font-size: 12.5px; }
|
||||
.confirm li { padding: 3px 0 3px 12px; position: relative; color: var(--ink); }
|
||||
.confirm li::before { content: '·'; position: absolute; left: 2px;
|
||||
color: var(--muted); }
|
||||
.confirm .row { display: flex; gap: 8px; }
|
||||
280
desktop/frontend/src/views/Cameras.jsx
Normal file
280
desktop/frontend/src/views/Cameras.jsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
import { MAKES, makeById } from '../../../../shared/cameraMakes.js'
|
||||
|
||||
const BLANK = { id: '', host: '', port: 554, path: '', username: '', password: '',
|
||||
max_width: 1280 }
|
||||
|
||||
export default function Cameras() {
|
||||
const { data, error, reload } = usePolled(() => api.cameras(), 8000)
|
||||
const [editing, setEditing] = useState(null)
|
||||
const [check, setCheck] = useState(null)
|
||||
const cams = data ?? []
|
||||
|
||||
async function remove(id) {
|
||||
if (!confirm(`Remove camera "${id}"? Recognition from it stops immediately.`)) return
|
||||
try { await api.deleteCamera(id); reload() } catch (e) { alert(message(e)) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h2>Cameras</h2>
|
||||
<p>Add a camera, check it can see faces properly, then it starts working.</p>
|
||||
</div>
|
||||
<button className="btn primary" onClick={() => setEditing({ ...BLANK })}>
|
||||
Add camera
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
{cams.length === 0
|
||||
? <div className="empty">No cameras yet.</div>
|
||||
: <div className="tablewrap">
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Address</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody>
|
||||
{cams.map(c => (
|
||||
<tr key={c.id}>
|
||||
<td>{c.id}</td>
|
||||
<td className="mono">{c.url}</td>
|
||||
<td>
|
||||
{c.connected === undefined
|
||||
? <span className="pill"><i className="dot idle" />stopped</span>
|
||||
: c.connected
|
||||
? <span className="pill ok"><i className="dot ok" />live</span>
|
||||
: <span className="pill bad"><i className="dot bad" />offline</span>}
|
||||
</td>
|
||||
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
|
||||
<button className="btn sm" onClick={() => setCheck(c.id)}>
|
||||
Check placement
|
||||
</button>{' '}
|
||||
<button className="btn sm" onClick={() => setEditing(c)}>Edit</button>{' '}
|
||||
<button className="btn sm danger" onClick={() => remove(c.id)}>
|
||||
Remove
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{editing && <CameraSheet cam={editing} onClose={() => setEditing(null)}
|
||||
onSaved={() => { setEditing(null); reload() }} />}
|
||||
{check && <PlacementSheet id={check} onClose={() => setCheck(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CameraSheet({ cam, onClose, onSaved }) {
|
||||
const isNew = !cam.id
|
||||
const [f, setF] = useState({ ...BLANK, ...cam, password: '',
|
||||
path: cam.path || (isNew ? MAKES[0].path : '') })
|
||||
const [make, setMake] = useState(isNew ? MAKES[0].id : 'manual')
|
||||
const [test, setTest] = useState(null)
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const set = k => e => setF({ ...f, [k]: e.target.value })
|
||||
|
||||
// Only overwrite the path when the preset has one, so choosing "I know the
|
||||
// path" does not wipe what the installer already typed.
|
||||
function chooseMake(e) {
|
||||
const m = makeById(e.target.value)
|
||||
setMake(m.id)
|
||||
setF(prev => ({ ...prev, path: m.path || prev.path }))
|
||||
}
|
||||
|
||||
// Blank means "leave alone", never "clear". The engine never returns a
|
||||
// stored password, so sending an empty one would wipe it on every edit.
|
||||
function payload() {
|
||||
const out = {}
|
||||
for (const [k, v] of Object.entries(f)) {
|
||||
if (v === '' || v === null || v === undefined) continue
|
||||
out[k] = (k === 'port' || k === 'max_width') ? Number(v) : v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
async function runTest() {
|
||||
setBusy('test'); setError(null); setTest(null)
|
||||
try { setTest(await api.testCamera(payload())) }
|
||||
catch (e) { setError(message(e)) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setBusy('save'); setError(null)
|
||||
try { await api.saveCamera(isNew ? '' : cam.id, payload()); onSaved() }
|
||||
catch (e) { setError(message(e)) } finally { setBusy(null) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>{isNew ? 'Add camera' : cam.id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Test the connection before saving — a wrong address is the most common mistake.
|
||||
</p>
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<label className="field">
|
||||
<span>Name</span>
|
||||
<input value={f.id} onChange={set('id')} disabled={!isNew}
|
||||
placeholder="entrance" required autoComplete="off" />
|
||||
</label>
|
||||
{/* The highest-value field on this form. The address and the
|
||||
password are on a label or in the installer's notes; the RTSP
|
||||
path is not written anywhere a shop owner would look, and getting
|
||||
it wrong produces "could not open stream", which reads like a
|
||||
password problem and is not. */}
|
||||
<label className="field"><span>Make of camera</span>
|
||||
<select value={make} onChange={chooseMake}>
|
||||
{MAKES.map(m => <option key={m.id} value={m.id}>{m.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
{makeById(make).note && (
|
||||
<p className="note" style={{ marginTop: -8, marginBottom: 12 }}>
|
||||
{makeById(make).note}
|
||||
</p>
|
||||
)}
|
||||
<div className="fieldrow">
|
||||
<label className="field"><span>Camera address</span>
|
||||
<input value={f.host} onChange={set('host')} placeholder="192.168.0.138"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Port</span>
|
||||
<input value={f.port} onChange={set('port')} inputMode="numeric"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field"><span>Stream path</span>
|
||||
<input value={f.path} onChange={set('path')} placeholder="/ch0_0.264"
|
||||
autoComplete="off" />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
{/* A text input next to a password input is a sign-in form as far
|
||||
as the webview is concerned, so without this the browser offers
|
||||
the operator's own Behavision email as the camera's username -
|
||||
which fails with a message about credentials that points at the
|
||||
camera. "off" alone is frequently ignored; a non-login name and
|
||||
new-password on the secret are what actually work. */}
|
||||
<label className="field"><span>Username</span>
|
||||
<input value={f.username} onChange={set('username')}
|
||||
name="camera-account" autoComplete="off" />
|
||||
</label>
|
||||
<label className="field"><span>Password</span>
|
||||
<input type="password" value={f.password} onChange={set('password')}
|
||||
name="camera-secret" autoComplete="new-password"
|
||||
placeholder={cam.has_password ? '(unchanged)' : ''} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 4 }}>
|
||||
<button type="button" className="btn" onClick={runTest} disabled={!!busy}>
|
||||
{busy === 'test' ? 'Connecting…' : 'Test connection'}
|
||||
</button>
|
||||
<button className="btn primary" disabled={!!busy || !f.id}>
|
||||
{busy === 'save' ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{test && (
|
||||
<div style={{ marginTop: 14 }}>
|
||||
{test.ok
|
||||
? <>
|
||||
<p style={{ color: 'var(--ok)', fontSize: 13 }}>
|
||||
Connected — {test.width}×{test.height}
|
||||
</p>
|
||||
{test.snapshot && (
|
||||
<img alt="Camera preview" style={{ width: '100%', marginTop: 8,
|
||||
borderRadius: 6, border: '1px solid var(--line)' }}
|
||||
src={`data:image/jpeg;base64,${test.snapshot}`} />
|
||||
)}
|
||||
</>
|
||||
: <div className="err">{test.error}</div>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The commissioning wizard. This is what stops a site being signed off with a
|
||||
// camera that recognises nobody — the failure that otherwise shows up weeks
|
||||
// later as a footfall report that was always zero.
|
||||
function PlacementSheet({ id, onClose }) {
|
||||
const [state, setState] = useState({ verdict: 'starting', advice: [] })
|
||||
const [error, setError] = useState(null)
|
||||
const timer = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
;(async () => {
|
||||
try {
|
||||
setState(await api.startPlacement(id, 25))
|
||||
timer.current = setInterval(async () => {
|
||||
try {
|
||||
const r = await api.placementResult(id)
|
||||
if (!alive) return
|
||||
setState(r)
|
||||
if (!r.running) clearInterval(timer.current)
|
||||
} catch (e) { if (alive) setError(message(e)) }
|
||||
}, 1000)
|
||||
} catch (e) { if (alive) setError(message(e)) }
|
||||
})()
|
||||
return () => { alive = false; clearInterval(timer.current) }
|
||||
}, [id])
|
||||
|
||||
const tone = { good: 'ok', marginal: 'warn', poor: 'bad', artifact: 'bad',
|
||||
no_faces: 'warn', inconclusive: 'warn' }[state.verdict]
|
||||
const pct = state.seconds ? Math.min(100, (state.elapsed / state.seconds) * 100) : 0
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<button className="btn sm close" onClick={onClose}>Close</button>
|
||||
<h3>Placement check — {id}</h3>
|
||||
<p className="note" style={{ marginBottom: 18 }}>
|
||||
Walk past the camera the way a customer would, a few times.
|
||||
</p>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card">
|
||||
<div style={{ fontSize: 15, fontWeight: 600,
|
||||
color: tone ? `var(--${tone})` : 'var(--ink)' }}>
|
||||
{state.headline || 'Starting…'}
|
||||
</div>
|
||||
{state.running && (
|
||||
<div style={{ height: 5, background: 'var(--surface-2)', borderRadius: 3,
|
||||
overflow: 'hidden', margin: '12px 0' }}>
|
||||
<div style={{ height: '100%', width: `${pct}%`, background: 'var(--accent)',
|
||||
transition: 'width .4s linear' }} />
|
||||
</div>
|
||||
)}
|
||||
{state.advice?.length > 0 && (
|
||||
<ul style={{ margin: '12px 0 0 18px', fontSize: 13, color: 'var(--ink-2)' }}>
|
||||
{state.advice.map((a, i) => <li key={i} style={{ marginBottom: 5 }}>{a}</li>)}
|
||||
</ul>
|
||||
)}
|
||||
{state.quality?.n > 0 && (
|
||||
<p className="note" style={{ marginTop: 12 }}>
|
||||
{state.quality.n} face{state.quality.n === 1 ? '' : 's'} seen ·
|
||||
median quality {state.quality.p50} ·
|
||||
gate {state.gate} ·
|
||||
{' '}{Math.round((state.quality.fraction_below_gate ?? 0) * 100)}% below it
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
182
desktop/frontend/src/views/CustomerForm.jsx
Normal file
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
import CustomerPhoto, { useCustomerPhoto } from './CustomerPhoto.jsx'
|
||||
import VisitHistory from './VisitHistory.jsx'
|
||||
import EraseCustomer from './EraseCustomer.jsx'
|
||||
|
||||
// The in-store form. Two jobs in one sheet: capture who this person is, and
|
||||
// record what they bought — because staff have the customer in front of them
|
||||
// once, and asking them to open a second screen means the sale never gets
|
||||
// recorded.
|
||||
export default function CustomerForm({ customer, session, onClose, onSaved }) {
|
||||
const [f, setF] = useState({
|
||||
full_name: customer.full_name ?? '',
|
||||
phone: customer.phone ?? '',
|
||||
email: customer.email ?? '',
|
||||
gender: '',
|
||||
date_of_birth: '',
|
||||
notes: '',
|
||||
consent: customer.has_consent ?? false,
|
||||
})
|
||||
const [purchase, setPurchase] = useState({ amount: '', items: '', notes: '' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
const [erasing, setErasing] = useState(false)
|
||||
const [photo, setPhoto] = useState(null)
|
||||
const fetched = useCustomerPhoto(customer.id)
|
||||
const shown = photo ?? fetched
|
||||
|
||||
// Erasure destroys a record permanently, so it is a manager's decision. The
|
||||
// server enforces this too — this only avoids offering a button that would
|
||||
// come back 403.
|
||||
const role = session?.user?.role
|
||||
const canErase = ['admin', 'owner', 'manager'].includes(role)
|
||||
|
||||
const set = k => e => setF({ ...f, [k]: e.target.value })
|
||||
|
||||
async function save(e) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.saveProfile({ visitor_id: customer.id, ...f })
|
||||
const amount = parseFloat(purchase.amount)
|
||||
if (!isNaN(amount) && amount > 0) {
|
||||
const items = purchase.items.split(',').map(s => s.trim()).filter(Boolean)
|
||||
await api.recordPurchase(customer.id, amount, items, purchase.notes)
|
||||
}
|
||||
onSaved()
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="drawer" onMouseDown={e => e.target === e.currentTarget && onClose()}>
|
||||
<div className="panel">
|
||||
<div className="who">
|
||||
<CustomerPhoto photo={shown} name={customer.full_name || customer.label}
|
||||
onBroken={() => setPhoto({ available: false,
|
||||
reason: 'The photo could not be loaded.' })} />
|
||||
<div className="grow">
|
||||
<h3>{customer.full_name || customer.label}</h3>
|
||||
<p className="note">
|
||||
{customer.visit_count} visit{customer.visit_count === 1 ? '' : 's'}
|
||||
{customer.last_seen_at && ` · last seen ${new Date(customer.last_seen_at).toLocaleDateString()}`}
|
||||
</p>
|
||||
{shown && !shown.available && shown.reason &&
|
||||
<p className="note">{shown.reason}</p>}
|
||||
</div>
|
||||
<button type="button" className="btn sm" onClick={onClose}>Close</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={save}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Customer details</h3>
|
||||
<label className="field">
|
||||
<span>Full name</span>
|
||||
<input value={f.full_name} onChange={set('full_name')} autoFocus />
|
||||
</label>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Phone</span>
|
||||
<input value={f.phone} onChange={set('phone')} inputMode="tel" />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input value={f.email} onChange={set('email')} type="email" />
|
||||
</label>
|
||||
</div>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Gender</span>
|
||||
<select value={f.gender} onChange={set('gender')}>
|
||||
<option value="">Not recorded</option>
|
||||
<option>Female</option><option>Male</option><option>Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Date of birth</span>
|
||||
<input type="date" value={f.date_of_birth} onChange={set('date_of_birth')} />
|
||||
</label>
|
||||
</div>
|
||||
<label className="field">
|
||||
<span>Notes</span>
|
||||
<textarea value={f.notes} onChange={set('notes')}
|
||||
placeholder="Preferences, sizes, anything worth remembering" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Purchase (optional)</h3>
|
||||
<div className="fieldrow">
|
||||
<label className="field">
|
||||
<span>Amount</span>
|
||||
<input value={purchase.amount} inputMode="decimal" placeholder="0.00"
|
||||
onChange={e => setPurchase({ ...purchase, amount: e.target.value })} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Items</span>
|
||||
<input value={purchase.items} placeholder="shirt, belt"
|
||||
onChange={e => setPurchase({ ...purchase, items: e.target.value })} />
|
||||
</label>
|
||||
</div>
|
||||
<p className="note">Leave the amount blank if they did not buy anything —
|
||||
a visit without a sale is still worth recording.</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 16 }}>
|
||||
<h3>Consent</h3>
|
||||
<label style={{ display: 'flex', gap: 10, alignItems: 'flex-start',
|
||||
fontSize: 13, cursor: 'pointer' }}>
|
||||
<input type="checkbox" checked={f.consent} style={{ marginTop: 3 }}
|
||||
onChange={e => setF({ ...f, consent: e.target.checked })} />
|
||||
<span>This customer agreed to us keeping their details and
|
||||
recognising them on future visits.</span>
|
||||
</label>
|
||||
<p className="note" style={{ marginTop: 10 }}>
|
||||
Recorded with the date and who collected it. They can withdraw it
|
||||
at any time, which erases their face data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ marginBottom: 14 }}>
|
||||
<h3>Visits</h3>
|
||||
<VisitHistory customer={customer} />
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button className="btn primary" disabled={busy}>
|
||||
{busy ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
|
||||
{canErase && (
|
||||
<div className="card danger-zone">
|
||||
<h3>At the customer's request</h3>
|
||||
{erasing
|
||||
? <EraseCustomer customer={customer}
|
||||
onCancel={() => setErasing(false)}
|
||||
onDone={onSaved} />
|
||||
: <>
|
||||
<p className="note">
|
||||
Erase this person's face data, photo and details. Their
|
||||
past visits stay in your footfall figures, without their
|
||||
name.
|
||||
</p>
|
||||
<button type="button" className="btn danger"
|
||||
onClick={() => setErasing(true)}>
|
||||
Erase this customer…
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
desktop/frontend/src/views/CustomerPhoto.jsx
Normal file
45
desktop/frontend/src/views/CustomerPhoto.jsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
|
||||
// The customer's face, when there is one.
|
||||
//
|
||||
// Fetched when the sheet opens rather than stored with the customer row: the
|
||||
// server hands out a signed link that expires in minutes, deliberately, so
|
||||
// that "delete my data" can actually make a picture stop loading. A link kept
|
||||
// in a list rendered an hour ago is a broken image.
|
||||
//
|
||||
// The fetch lives in a hook and happens ONCE per sheet, because the server
|
||||
// writes an audit_log row for every read of a face image — "who looked at my
|
||||
// customers" has to be answerable — and a component that fetched its own copy
|
||||
// for the picture and again for the caption would put two rows in that log for
|
||||
// one glance at one person.
|
||||
export function useCustomerPhoto(id) {
|
||||
const [photo, setPhoto] = useState(null)
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
setPhoto(null)
|
||||
api.visitorPhoto(id)
|
||||
.then(p => { if (alive) setPhoto(p) })
|
||||
// A failure to load a photo must never take the customer record with
|
||||
// it: the name and phone number are what staff opened this for.
|
||||
.catch(() => { if (alive) setPhoto({ available: false, reason: '' }) })
|
||||
return () => { alive = false }
|
||||
}, [id])
|
||||
return photo
|
||||
}
|
||||
|
||||
// No photo is the normal case — images are off by default — so this renders
|
||||
// initials, not an error.
|
||||
export default function CustomerPhoto({ photo, name, onBroken }) {
|
||||
if (photo?.available) {
|
||||
return <img className="avatar" src={photo.url} alt={`Photo of ${name}`}
|
||||
onError={onBroken} />
|
||||
}
|
||||
const initials = String(name || '').split(/\s+/).filter(Boolean).slice(0, 2)
|
||||
.map(w => w[0].toUpperCase()).join('') || '?'
|
||||
return (
|
||||
<div className="avatar none" role="img" aria-label={`No photo of ${name}`}>
|
||||
<span>{initials}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
92
desktop/frontend/src/views/Customers.jsx
Normal file
92
desktop/frontend/src/views/Customers.jsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtDate } from '../hooks.js'
|
||||
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>
|
||||
<h2>Customers</h2>
|
||||
<p>Everyone this business has recognised. Fill in details once and they
|
||||
are known at every store.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="Known people" value={rows.length || '—'} />
|
||||
<Stat label="With details" value={named || '—'}
|
||||
sub={rows.length ? `${Math.round(100 * named / rows.length)}% captured` : null} />
|
||||
<Stat label="Returning" value={rows.filter(r => r.visit_count > 1).length || '—'} />
|
||||
<Stat label="With consent" value={rows.filter(r => r.has_consent).length || '—'} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div style={{ display: 'flex', gap: 10, marginBottom: 12 }}>
|
||||
<input className="field" style={{ flex: 1, margin: 0, background: 'var(--ground)',
|
||||
border: '1px solid var(--line)', borderRadius: 6, padding: '8px 10px' }}
|
||||
placeholder="Search by name or phone"
|
||||
value={query} onChange={e => setQuery(e.target.value)} />
|
||||
<button className="btn" onClick={reload}>Refresh</button>
|
||||
</div>
|
||||
|
||||
{rows.length === 0
|
||||
? <div className="empty">
|
||||
No customers yet. They appear here the first time a camera sees them.
|
||||
</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>}</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 Stat({ label, value, sub }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3><div className="value">{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
89
desktop/frontend/src/views/EraseCustomer.jsx
Normal file
89
desktop/frontend/src/views/EraseCustomer.jsx
Normal file
@@ -0,0 +1,89 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
|
||||
// Erasing a customer, at that customer's request.
|
||||
//
|
||||
// This is a legal obligation with a destructive implementation, so the screen
|
||||
// does two things a plain "are you sure" cannot. It says exactly what is
|
||||
// destroyed and exactly what is kept — staff are asked "will you delete my
|
||||
// data?" by a real person standing in front of them and have to be able to
|
||||
// answer truthfully — and it requires the customer's name to be typed, because
|
||||
// this sits next to Save in a sheet used all day and a misclick is
|
||||
// unrecoverable.
|
||||
//
|
||||
// What is kept is not an oversight. Visits stay (unlinked): they are the
|
||||
// shop's own footfall history, and silently rewriting last quarter's numbers
|
||||
// because one customer exercised a right is both wrong and detectable. Consent
|
||||
// stays, revoked: deleting it destroys the proof of what we were permitted to
|
||||
// do and when, which is the first thing an auditor asks for.
|
||||
export default function EraseCustomer({ customer, onCancel, onDone }) {
|
||||
const name = customer.full_name || customer.label
|
||||
const [typed, setTyped] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
const confirmed = typed.trim().toLowerCase() === name.trim().toLowerCase()
|
||||
|
||||
async function erase() {
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
await api.forgetCustomer(customer.id)
|
||||
onDone()
|
||||
} catch (err) {
|
||||
// The server deletes stored photos before it touches the database and
|
||||
// refuses the whole request if one fails, so a failure here means
|
||||
// nothing was erased. Say so — the alternative is a shop believing a
|
||||
// request was honoured when it was not.
|
||||
setError(message(err))
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="confirm">
|
||||
<h4>Erase {name}?</h4>
|
||||
|
||||
<div className="cols">
|
||||
<div>
|
||||
<p className="lbl bad">Deleted for good</p>
|
||||
<ul>
|
||||
<li>Their face data — they will not be recognised again</li>
|
||||
<li>Their photo</li>
|
||||
<li>Their name, phone, email and notes</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<p className="lbl">Kept</p>
|
||||
<ul>
|
||||
<li>Past visits, with their name removed — your footfall figures
|
||||
do not change</li>
|
||||
<li>The consent record, marked withdrawn, as proof of what was
|
||||
agreed</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="note">This cannot be undone. If they come back they will be
|
||||
recorded as a new customer.</p>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<label className="field">
|
||||
<span>Type <b>{name}</b> to confirm</span>
|
||||
<input value={typed} onChange={e => setTyped(e.target.value)}
|
||||
autoFocus autoComplete="off" spellCheck="false"
|
||||
aria-label={`Type ${name} to confirm erasure`} />
|
||||
</label>
|
||||
|
||||
<div className="row">
|
||||
<button type="button" className="btn danger"
|
||||
disabled={!confirmed || busy} onClick={erase}>
|
||||
{busy ? 'Erasing…' : 'Erase permanently'}
|
||||
</button>
|
||||
<button type="button" className="btn" onClick={onCancel} disabled={busy}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
162
desktop/frontend/src/views/Footfall.jsx
Normal file
162
desktop/frontend/src/views/Footfall.jsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, daysAgo, today } from '../hooks.js'
|
||||
|
||||
const RANGES = [
|
||||
{ label: '7 days', days: 7, bucket: 'day' },
|
||||
{ label: '30 days', days: 30, bucket: 'day' },
|
||||
{ label: '90 days', days: 90, bucket: 'week' },
|
||||
]
|
||||
|
||||
export default function Footfall() {
|
||||
const [range, setRange] = useState(RANGES[0])
|
||||
const { data, error, loading } = usePolled(
|
||||
() => api.footfall(daysAgo(range.days), today(), range.bucket),
|
||||
60000, [range.days, range.bucket])
|
||||
const sites = usePolled(() => api.sites(), 60000, [])
|
||||
|
||||
const points = data?.points ?? []
|
||||
const peak = Math.max(1, ...points.map(p => p.visitors))
|
||||
|
||||
// Both numbers come from the server, and neither is the sum of the chart.
|
||||
//
|
||||
// A customer who came on Monday and Thursday is ONE person and TWO
|
||||
// bucket-visitors, so adding the bars up gives a headcount that is silently
|
||||
// too high. Summing new + returning is wrong a second way: a site sending
|
||||
// counts without face templates produces visits that are real footfall but
|
||||
// an unknown person, and those are counted in neither.
|
||||
const unique = data?.total ?? 0
|
||||
const visits = data?.visits ?? 0
|
||||
const totalNew = points.reduce((n, p) => n + (p.new ?? 0), 0)
|
||||
const totalRet = points.reduce((n, p) => n + (p.returning ?? 0), 0)
|
||||
const identified = totalNew + totalRet
|
||||
const gate = data?.fraction_below_gate ?? 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h2>Footfall</h2>
|
||||
<p>Visitors over time, split by whether we had seen them before.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{RANGES.map(r => (
|
||||
<button key={r.label} className="btn sm"
|
||||
aria-pressed={r.days === range.days}
|
||||
style={r.days === range.days
|
||||
? { background: 'var(--accent-dim)', color: 'var(--accent)',
|
||||
borderColor: 'var(--accent)' } : undefined}
|
||||
onClick={() => setRange(r)}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People" value={unique || '—'}
|
||||
sub={visits ? `${visits} visit${visits === 1 ? '' : 's'} in total` : null} />
|
||||
<Stat label="New" value={totalNew || '—'} />
|
||||
<Stat label="Returning" value={totalRet || '—'}
|
||||
sub={identified ? `${Math.round(100 * totalRet / identified)}% of recognised visits` : null} />
|
||||
<Stat label="Below quality gate"
|
||||
value={data ? `${Math.round(gate * 100)}%` : '—'}
|
||||
tone={gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : undefined}
|
||||
sub={data?.worst_site ? `worst: ${data.worst_site}` : 'Faces seen but too poor to count'} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Visitors per {range.bucket}</h3>
|
||||
{loading && !points.length ? <div className="empty">Loading…</div>
|
||||
: points.length === 0 ? <div className="empty">No visits recorded in this period.</div>
|
||||
: <>
|
||||
<div className="bars">
|
||||
{points.map((p, i) => (
|
||||
<div className="col" key={i}
|
||||
title={`${bucketLabel(p.bucket, range.bucket)}: ${p.visitors} visitor${p.visitors === 1 ? '' : 's'}`}>
|
||||
<div className="seg new" style={{ height: `${(p.new / peak) * 100}%` }} />
|
||||
<div className="seg ret" style={{ height: `${(p.returning / peak) * 100}%` }} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="axis">
|
||||
<span>{bucketLabel(points[0]?.bucket, range.bucket)}</span>
|
||||
<span>{bucketLabel(points[points.length - 1]?.bucket, range.bucket)}</span>
|
||||
</div>
|
||||
<div className="key">
|
||||
<span><i style={{ background: 'var(--accent)' }} />Returning</span>
|
||||
<span><i style={{ background: '#2f6f81' }} />New</span>
|
||||
{data?.timezone && <span style={{ marginLeft: 'auto', opacity: 0.6 }}>
|
||||
times in {data.timezone}</span>}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<Sites sites={sites.data} error={sites.error} />
|
||||
|
||||
{gate > 0.5 && (
|
||||
<div className="err" style={{ marginTop: 16 }}>
|
||||
More than half the faces {data.worst_site ? `at ${data.worst_site}` : 'this site'} saw
|
||||
were too poor to count, so this chart understates real footfall. Run a
|
||||
placement check on that camera before trusting these numbers.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// A site that has stopped reporting looks exactly like a site with no
|
||||
// customers - the same row of zeroes - and only one of them is something to
|
||||
// act on. This is the difference, shown next to the chart it explains.
|
||||
function Sites({ sites, error }) {
|
||||
if (error || !sites || sites.length === 0) return null
|
||||
return (
|
||||
<div className="card" style={{ marginTop: 16 }}>
|
||||
<h3>Sites</h3>
|
||||
<table>
|
||||
<tbody>
|
||||
{sites.map(s => (
|
||||
<tr key={s.site_id}>
|
||||
<td><b>{s.name}</b></td>
|
||||
<td>
|
||||
{/* .dot is sized, so it needs a box: a bare span is inline
|
||||
and would collapse to nothing inside a table cell. */}
|
||||
<span className={`dot ${s.online ? 'ok' : 'bad'}`}
|
||||
style={{ display: 'inline-block', marginRight: 6 }} />
|
||||
{s.online ? 'Reporting' : 'Not reporting'}
|
||||
</td>
|
||||
<td>{s.cameras_total
|
||||
? `${s.cameras_up}/${s.cameras_total} camera${s.cameras_total === 1 ? '' : 's'} connected`
|
||||
: 'no cameras'}</td>
|
||||
{/* Dropped events are footfall this site permanently lost, so it
|
||||
has to be visible rather than inferred from a dip in a graph. */}
|
||||
<td>{s.dropped > 0
|
||||
? <span style={{ color: 'var(--bad)' }}>{s.dropped} events lost</span>
|
||||
: s.queued > 0 ? `${s.queued} queued` : ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Buckets come back as local wall time with no offset, labelled by the
|
||||
// timezone in the report. Parsing them as a Date would re-interpret them in
|
||||
// the viewer's zone and shift every label by hours.
|
||||
function bucketLabel(bucket, size) {
|
||||
if (!bucket) return ''
|
||||
const [date, time] = bucket.split('T')
|
||||
if (size === 'hour') return `${date.slice(5)} ${(time || '').slice(0, 5)}`
|
||||
return date
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
192
desktop/frontend/src/views/Live.jsx
Normal file
192
desktop/frontend/src/views/Live.jsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, fmtTime } from '../hooks.js'
|
||||
|
||||
// What is happening right now. The first screen a shop manager opens, so it
|
||||
// answers "is it working" before it answers anything else.
|
||||
export default function Live() {
|
||||
const { data, error } = usePolled(() => api.live(), 3000)
|
||||
const { data: pipe } = usePolled(() => api.pipelineStatus(), 5000)
|
||||
const cams = useCameraFeeds()
|
||||
|
||||
const cameras = data?.stats?.cameras ?? []
|
||||
const gallery = data?.stats?.gallery ?? {}
|
||||
const events = data?.events ?? []
|
||||
|
||||
// fraction_below_gate is the number that decides a site: what share of the
|
||||
// faces this camera saw were too poor to enrol. Surfaced here rather than
|
||||
// buried, because a high value looks exactly like "a quiet day".
|
||||
const worst = cameras.reduce((acc, c) => {
|
||||
const f = c?.pipeline?.best_quality?.fraction_below_gate
|
||||
return typeof f === 'number' && f > acc ? f : acc
|
||||
}, 0)
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header>
|
||||
<h2>Live</h2>
|
||||
<p>Cameras, recent detections, and whether this site is recognising people.</p>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="People known" value={gallery.identities ?? '—'} />
|
||||
<Stat label="Sightings" value={gallery.sightings ?? '—'} />
|
||||
<Stat label="Cameras live"
|
||||
value={`${cameras.filter(c => c.connected).length}/${cameras.length || 0}`} />
|
||||
<Stat label="Below quality gate"
|
||||
value={cameras.length ? `${Math.round(worst * 100)}%` : '—'}
|
||||
tone={worst > 0.5 ? 'bad' : worst > 0.2 ? 'warn' : 'ok'}
|
||||
sub={worst > 0.5 ? 'Most visitors are being missed — check camera placement'
|
||||
: 'Share of faces too poor to enrol'} />
|
||||
</div>
|
||||
|
||||
<Pipeline pipe={pipe} />
|
||||
|
||||
<div className="grid cols-2">
|
||||
<div>
|
||||
<div className="card">
|
||||
<h3>Cameras</h3>
|
||||
{cameras.length === 0
|
||||
? <div className="empty">No cameras yet. Add one in Cameras.</div>
|
||||
: <div className="feeds">
|
||||
{cameras.map(c => (
|
||||
<div className="feed" key={c.camera_id}>
|
||||
{cams[c.camera_id]
|
||||
? <img src={cams[c.camera_id]} alt={c.camera_id} />
|
||||
: <div style={{ aspectRatio: '16/9' }} />}
|
||||
<div className="cap">
|
||||
<span>{c.camera_id}</span>
|
||||
<span className={`pill ${c.connected ? 'ok' : 'bad'}`}>
|
||||
<i className={`dot ${c.connected ? 'ok' : 'bad'}`} />
|
||||
{c.connected ? 'live' : 'offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Recent detections</h3>
|
||||
{events.length === 0
|
||||
? <div className="empty">Nothing detected yet.</div>
|
||||
: <ul className="events">
|
||||
{events.map((e, i) => <EventRow key={i} e={e} />)}
|
||||
</ul>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Whether anything is actually reaching head office. Without this the app can
|
||||
// look perfectly healthy while every detection piles up on disk unsent — which
|
||||
// is exactly what it did before the bridge existed.
|
||||
function Pipeline({ pipe }) {
|
||||
if (!pipe) return null
|
||||
// A PC set up on its own is not "not linked yet" — nothing is coming, and
|
||||
// saying so with an idle dot beside a count of zero reads as a fault.
|
||||
if (pipe.standalone) {
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 10, alignItems: 'center' }}>
|
||||
<i className="dot ok" />
|
||||
<strong style={{ fontSize: 13 }}>Running on this PC only</strong>
|
||||
<span className="note">Recognition and customers stay here.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const stuck = pipe.claimed && !pipe.broker_up
|
||||
return (
|
||||
<div className="card" style={{ marginBottom: 16, display: 'flex',
|
||||
gap: 22, alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<i className={`dot ${!pipe.claimed ? 'idle' : pipe.broker_up ? 'ok' : 'bad'}`} />
|
||||
<strong style={{ fontSize: 13 }}>
|
||||
{!pipe.claimed ? 'Not linked to head office'
|
||||
: pipe.broker_up ? 'Sending to head office' : 'Offline — saving locally'}
|
||||
</strong>
|
||||
</span>
|
||||
<span className="note">{pipe.accepted} recorded today</span>
|
||||
{pipe.queued > 0 && (
|
||||
<span className="note" style={stuck ? { color: 'var(--warn)' } : undefined}>
|
||||
{pipe.queued} waiting to send
|
||||
</span>
|
||||
)}
|
||||
{pipe.dropped > 0 && (
|
||||
<span className="note" style={{ color: 'var(--bad)' }}>
|
||||
{pipe.dropped} lost — this PC was offline too long
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EventRow({ e }) {
|
||||
const cls = e.type === 'person.new' ? 'new'
|
||||
: e.type === 'person.seen' ? 'seen'
|
||||
: e.type === 'person.missed' ? 'miss' : ''
|
||||
const age = e.data?.age ?? e.data?.age_range
|
||||
const extra = [e.data?.gender, age, e.data?.emotion].filter(Boolean).join(', ')
|
||||
return (
|
||||
<li>
|
||||
<span className="when">{fmtTime(e.ts)}</span>
|
||||
<span className={`tag ${cls}`}>{label(e.type)}</span>
|
||||
<span style={{ flex: 1, minWidth: 0 }}>
|
||||
{e.data?.label || e.camera_id}
|
||||
{extra && <span className="note"> · {extra}</span>}
|
||||
</span>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// The event names are internal; a shop manager should not have to learn them.
|
||||
function label(type) {
|
||||
return {
|
||||
'person.new': 'new',
|
||||
'person.seen': 'returning',
|
||||
'person.missed': 'missed',
|
||||
'camera.up': 'camera up',
|
||||
'camera.down': 'camera down',
|
||||
'identity.merged': 'merged',
|
||||
}[type] ?? type
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>
|
||||
{value}
|
||||
</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Stream URLs are fetched once per camera and then left alone: reassigning an
|
||||
// MJPEG <img> src restarts the stream, so rebuilding them on every poll would
|
||||
// make every feed flicker permanently.
|
||||
function useCameraFeeds() {
|
||||
const [urls, setUrls] = useState({})
|
||||
const { data } = usePolled(() => api.cameras(), 10000)
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
const next = {}
|
||||
for (const cam of data ?? []) {
|
||||
if (urls[cam.id]) { next[cam.id] = urls[cam.id]; continue }
|
||||
try { next[cam.id] = await api.streamURL(cam.id) } catch { /* engine down */ }
|
||||
}
|
||||
const changed = Object.keys(next).length !== Object.keys(urls).length ||
|
||||
Object.keys(next).some(k => next[k] !== urls[k])
|
||||
if (!cancelled && changed) setUrls(next)
|
||||
})()
|
||||
return () => { cancelled = true }
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
return urls
|
||||
}
|
||||
52
desktop/frontend/src/views/Login.jsx
Normal file
52
desktop/frontend/src/views/Login.jsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
|
||||
// The gate. Nothing else in the app is reachable until this succeeds, because
|
||||
// the broker credentials and the customer database both live behind it.
|
||||
export default function Login({ onDone }) {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState(null)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError(null)
|
||||
try {
|
||||
onDone(await api.login(email.trim(), password))
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<h1>Behavision</h1>
|
||||
<p className="lead">Sign in to connect this PC to your store.</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<label className="field">
|
||||
<span>Email</span>
|
||||
<input type="email" value={email} autoComplete="username" required
|
||||
autoFocus onChange={e => setEmail(e.target.value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Password</span>
|
||||
<input type="password" value={password} autoComplete="current-password"
|
||||
required onChange={e => setPassword(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={busy || !email || !password}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
Signing in downloads this store's recognition models and connects it
|
||||
to your account. Nothing is sent until a camera is set up.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
88
desktop/frontend/src/views/Sales.jsx
Normal file
88
desktop/frontend/src/views/Sales.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import { useState } from 'react'
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled, daysAgo, today } from '../hooks.js'
|
||||
|
||||
const RANGES = [{ label: '7 days', days: 7 }, { label: '30 days', days: 30 },
|
||||
{ label: '90 days', days: 90 }]
|
||||
|
||||
// Footfall against sales. The number the business actually judges the product
|
||||
// by: how many of the people who walked in bought something.
|
||||
export default function Sales() {
|
||||
const [range, setRange] = useState(RANGES[1])
|
||||
const { data, error } = usePolled(
|
||||
() => api.sales(daysAgo(range.days), today()), 60000, [range.days])
|
||||
|
||||
const cur = data?.currency || 'INR'
|
||||
const money = n => typeof n === 'number'
|
||||
? new Intl.NumberFormat(undefined, { style: 'currency', currency: cur,
|
||||
maximumFractionDigits: 0 }).format(n)
|
||||
: '—'
|
||||
const conv = data?.conversion ?? 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<header style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end' }}>
|
||||
<div>
|
||||
<h2>Sales summary</h2>
|
||||
<p>How many of the people who came in actually bought something.</p>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6 }}>
|
||||
{RANGES.map(r => (
|
||||
<button key={r.label} className="btn sm"
|
||||
style={r.days === range.days
|
||||
? { background: 'var(--accent-dim)', color: 'var(--accent)',
|
||||
borderColor: 'var(--accent)' } : undefined}
|
||||
onClick={() => setRange(r)}>{r.label}</button>
|
||||
))}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{error && <div className="err">{error}</div>}
|
||||
|
||||
<div className="grid cols-4" style={{ marginBottom: 16 }}>
|
||||
<Stat label="Visitors" value={data?.visitors ?? '—'} />
|
||||
<Stat label="Bought something" value={data?.purchasers ?? '—'} />
|
||||
<Stat label="Conversion" value={data ? `${Math.round(conv * 100)}%` : '—'}
|
||||
tone={conv >= 0.3 ? 'ok' : conv > 0 ? 'warn' : undefined} />
|
||||
<Stat label="Revenue" value={money(data?.revenue)}
|
||||
sub={data ? `${money(data.average_basket)} average basket` : null} />
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h3>Visitors who bought</h3>
|
||||
{!data ? <div className="empty">Loading…</div>
|
||||
: data.visitors === 0
|
||||
? <div className="empty">No visits recorded in this period.</div>
|
||||
: <>
|
||||
<div style={{ display: 'flex', height: 34, borderRadius: 6,
|
||||
overflow: 'hidden', border: '1px solid var(--line)' }}>
|
||||
<div style={{ width: `${conv * 100}%`, background: 'var(--accent)' }} />
|
||||
<div style={{ flex: 1, background: 'var(--surface-2)' }} />
|
||||
</div>
|
||||
<div className="key">
|
||||
<span><i style={{ background: 'var(--accent)' }} />
|
||||
Bought — {data.purchasers}</span>
|
||||
<span><i style={{ background: 'var(--surface-2)' }} />
|
||||
Left without buying — {data.visitors - data.purchasers}</span>
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
<p className="note" style={{ marginTop: 14 }}>
|
||||
Sales come from what staff enter on the customer form. A visit with no
|
||||
amount counts as a visit that did not convert, which is what makes this
|
||||
figure meaningful.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, sub, tone }) {
|
||||
return (
|
||||
<div className="card stat">
|
||||
<h3>{label}</h3>
|
||||
<div className="value" style={tone ? { color: `var(--${tone})` } : undefined}>{value}</div>
|
||||
{sub && <div className="sub">{sub}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
103
desktop/frontend/src/views/Setup.jsx
Normal file
103
desktop/frontend/src/views/Setup.jsx
Normal file
@@ -0,0 +1,103 @@
|
||||
import { useState } from 'react'
|
||||
import { api, message } from '../bridge.js'
|
||||
|
||||
// Linking this PC to a shop — the first thing that happens on a new install,
|
||||
// and until now the one thing the app could not do.
|
||||
//
|
||||
// It comes BEFORE sign-in on purpose. The installer standing at a new counter
|
||||
// has an installation code and, quite often, no account of their own yet; the
|
||||
// PC's identity is not a person's identity. The endpoint behind this is
|
||||
// deliberately unauthenticated for the same reason — requiring a login first
|
||||
// would mean shipping a password to every shop that installs the software.
|
||||
export default function Setup({ onDone, onCancel }) {
|
||||
const [code, setCode] = useState('')
|
||||
const [busy, setBusy] = useState(null)
|
||||
const [error, setError] = useState(null)
|
||||
const [alone, setAlone] = useState(false)
|
||||
|
||||
async function submit(e) {
|
||||
e.preventDefault()
|
||||
setBusy('claim'); setError(null)
|
||||
try {
|
||||
onDone(await api.claim(code))
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function standalone() {
|
||||
setBusy('alone'); setError(null)
|
||||
try {
|
||||
onDone(await api.runStandalone())
|
||||
} catch (err) {
|
||||
setError(message(err))
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="login">
|
||||
<div className="box">
|
||||
<h1>{onCancel ? 'Link to head office' : 'Set up this PC'}</h1>
|
||||
<p className="lead">
|
||||
Type the installation code for this shop. You only do this once.
|
||||
</p>
|
||||
<form onSubmit={submit}>
|
||||
{error && <div className="err">{error}</div>}
|
||||
<label className="field">
|
||||
<span>Installation code</span>
|
||||
{/* Uppercase and letter-spaced because the code arrives read aloud
|
||||
down a phone or photographed off a screen. Spaces, dashes and
|
||||
case are stripped on the server, so what is typed here can be
|
||||
as untidy as it needs to be. */}
|
||||
<input value={code} autoFocus required
|
||||
placeholder="ABCDEF-123456-GHIJKL-789012"
|
||||
autoComplete="off" spellCheck="false"
|
||||
style={{ textTransform: 'uppercase', letterSpacing: '.06em' }}
|
||||
onChange={e => setCode(e.target.value)} />
|
||||
</label>
|
||||
<button className="btn primary" disabled={!!busy || code.trim().length < 6}>
|
||||
{busy === 'claim' ? 'Linking…' : 'Link this PC'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="foot">
|
||||
The code works once. Ask whoever manages your shops for it — they can
|
||||
create one from the Behavision platform, under the shop.
|
||||
</p>
|
||||
|
||||
{/* The second way out of this screen, and the reason it exists.
|
||||
Recognition, the cameras and this shop's own gallery all run on
|
||||
this PC and need no server, so a shop with one till and no head
|
||||
office was being blocked from adding a camera until somebody
|
||||
issued it a code — the software refusing to do the thing it is
|
||||
for. Linking later is still one click away, and it keeps the
|
||||
visits already recorded here. */}
|
||||
<div className="alt">
|
||||
{onCancel
|
||||
? <button type="button" className="linkbtn" onClick={onCancel}>
|
||||
Not now — go back
|
||||
</button>
|
||||
: !alone
|
||||
? <button type="button" className="linkbtn" onClick={() => setAlone(true)}>
|
||||
No head office — set this PC up on its own
|
||||
</button>
|
||||
: <>
|
||||
<p className="note">
|
||||
This PC will watch its cameras and recognise returning
|
||||
customers on its own. Nothing is sent anywhere. You can link
|
||||
it to head office later without losing anything recorded
|
||||
here.
|
||||
</p>
|
||||
<button type="button" className="btn" disabled={!!busy}
|
||||
onClick={standalone}>
|
||||
{busy === 'alone' ? 'Setting up…' : 'Use this PC on its own'}
|
||||
</button>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
51
desktop/frontend/src/views/VisitHistory.jsx
Normal file
51
desktop/frontend/src/views/VisitHistory.jsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { api } from '../bridge.js'
|
||||
import { usePolled } from '../hooks.js'
|
||||
|
||||
// One customer's timeline.
|
||||
//
|
||||
// The endpoint and the binding for this both already existed and nothing
|
||||
// called them, which meant the product could recognise a returning customer
|
||||
// and then had no screen able to say when they had been before — the single
|
||||
// question staff ask about a regular.
|
||||
//
|
||||
// Not polled: a record sheet open on a counter should not re-query every few
|
||||
// seconds, and the visit that matters is happening at the counter, not in the
|
||||
// list.
|
||||
export default function VisitHistory({ customer }) {
|
||||
const { data, error, loading } = usePolled(
|
||||
() => api.visitorHistory(customer.id, 50), 0, [customer.id])
|
||||
|
||||
if (loading) return <p className="note">Loading visits…</p>
|
||||
if (error) return <p className="note">Could not load visits: {error}</p>
|
||||
|
||||
const visits = data ?? []
|
||||
if (visits.length === 0) {
|
||||
return <p className="note">No recorded visits yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="timeline">
|
||||
{visits.map(v => (
|
||||
<li key={v.id}>
|
||||
<span className="when">{whenLabel(v.occurred_at)}</span>
|
||||
<span className="where">
|
||||
{v.site || 'this store'}
|
||||
{v.camera_id ? <span className="note"> · {v.camera_id}</span> : null}
|
||||
</span>
|
||||
{v.is_new_visitor && <span className="tag new">first visit</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
// The server returns visit times as an instant; showing the date and the time
|
||||
// of day matters more than precision here — "Tuesday afternoon" is how staff
|
||||
// remember a customer.
|
||||
function whenLabel(iso) {
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d)) return '—'
|
||||
return d.toLocaleString([], {
|
||||
day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
}
|
||||
14
desktop/frontend/vite.config.js
Normal file
14
desktop/frontend/vite.config.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
// Wails serves these from an embedded FS at the root, so relative asset
|
||||
// paths are required - absolute ones 404 inside the webview.
|
||||
assetsDir: 'assets',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
base: './',
|
||||
})
|
||||
Reference in New Issue
Block a user