Files
Behavision/web/src/views/Sites.jsx
Suriyakumarvijayanayagam f88d441bbf A shop can be renamed and, while empty, removed - from head office
The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 16:11:07 +05:30

350 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState } from 'react'
import { api } from '../api.js'
import { usePolled } from '../hooks.js'
import Shot from './Shot.jsx'
import SiteCheck from './SiteCheck.jsx'
// The estate at a glance.
//
// The question this answers is not "how busy were we" — it is "is any of this
// actually working". A shop whose PC has been unplugged for a week and a shop
// with no customers produce the same row of zeroes everywhere else in the
// product, and only one of them is something to act on.
//
// Laid out like the camera screen, and for the same reason: the first version
// was a definition list of cameras_up, fraction_below_gate and last_heartbeat,
// which is the view a developer wants. An owner opening this wants to SEE their
// shops. So the shop's own camera view is the card, the numbers sit under it,
// and one line says what to do — with the technical detail one click away
// rather than on the surface.
export default function Sites({ user }) {
const { data, error, loading, reload } = usePolled(() => api.sites(), 20000, [])
// Cameras come from a second call and are joined here rather than server-side:
// the picture is decoration on this screen, so it must never be able to make
// the health list fail. If this errors the cards simply have no photograph.
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
const [checking, setChecking] = useState(null)
const [opening, setOpening] = useState(false)
const sites = data || []
// Opening a shop is the owner's: it is a billing and tenancy object, not a
// setting. Managers set up the PC and cameras once it exists.
const canOpen = user?.role === 'owner'
if (loading && !data) return <Loading />
if (error) return <Problem error={error} />
if (!sites.length && !opening) return <Empty canOpen={canOpen} onOpen={() => setOpening(true)} />
// Counted from the same verdicts the cards show. Summarising with a second,
// simpler rule up here is how a header ends up reading "all working" over a
// grid containing a red card.
const rows = sites.map(s => {
const own = (cams || []).filter(c => c.site_id === s.site_id)
return { site: s, cams: own, verdict: verdictFor(s, own) }
})
const tally = t => rows.filter(r => r.verdict.tone === t).length
const broken = tally('bad'), watch = tally('warn'), fresh = tally('idle')
return (
<>
<header className="head">
<h1>Shops</h1>
<p className="sub">
{sites.length} {sites.length === 1 ? 'shop' : 'shops'}
{broken > 0 && <> · <b className="bad">{broken} not working</b></>}
{watch > 0 && <> · <b className="warn">{watch} needing attention</b></>}
{fresh > 0 && <> · {fresh} not set up yet</>}
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
</p>
{canOpen && (
<button className="primary" onClick={() => setOpening(true)}>Open a new shop</button>
)}
</header>
<div className="grid sites">
{rows.map(r => (
<SiteCard key={r.site.site_id} site={r.site} cams={r.cams}
verdict={r.verdict} onCheck={() => setChecking(r.site)} />
))}
</div>
{/* Reachable per shop, at last. The smoke test used to hang off a single
button on the camera screen that always checked sites[0], so with two
shops the second could not be checked at all. */}
{checking && <SiteCheck site={checking} user={user} onClose={() => setChecking(null)} onChanged={reload} />}
{opening && <NewShop onClose={() => setOpening(false)}
onCreated={() => { setOpening(false); reload() }} />}
</>
)
}
// Until this existed a shop was a command on the server plus a hand edit of
// the broker's password file - every new branch a support ticket, and the one
// piece of onboarding that could not be done from the product. The server now
// registers the shop's broker login as it creates the row, so the next step
// really is just "Set up a shop PC" on the new card.
function NewShop({ onClose, onCreated }) {
const [form, setForm] = useState({ name: '', slug: '', timezone: 'Asia/Kolkata' })
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
const submit = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
onCreated(await api.createSite({ name: form.name, slug: form.slug || undefined, timezone: form.timezone }))
} catch (err) {
setError(err.message)
setBusy(false)
}
}
return (
<div className="overlay" onClick={onClose}>
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
<header className="drawer-head">
<h2>Open a new shop</h2>
<button className="ghost" onClick={onClose}>Close</button>
</header>
<form className="drawer-body" onSubmit={submit}>
<label>Shop name
<input value={form.name} onChange={set('name')} required autoFocus placeholder="TeNext Bengaluru" />
</label>
<label>Short name
<input value={form.slug} onChange={set('slug')} placeholder="made from the name if left empty" />
<span className="hint">
Lower-case letters, digits and dashes. It becomes the shop PC’s
identity and cannot be changed afterwards.
</span>
</label>
<label>Timezone
<input value={form.timezone} onChange={set('timezone')} required />
<span className="hint">Footfall is bucketed by the shop’s own clock, e.g. Asia/Kolkata.</span>
</label>
{error && <p className="error" role="alert">{error}</p>}
<button className="primary" disabled={busy || !form.name.trim()}>
{busy ? 'Opening…' : 'Open shop'}
</button>
</form>
</aside>
</div>
)
}
// The one line the card leads with, in severity order. Only the first is shown:
// a shop that is offline AND has a bad camera needs its PC turned on first, and
// listing both invites someone to start with the wrong one.
//
// This is also the ONLY place a shop's health is decided. The pill over the
// picture, the stripe down the edge and this line all read from it, because the
// first version computed the pill separately from online + gate and a shop with
// two dead cameras came out labelled "Working" directly above the words "2 of 3
// cameras not connecting". Two surfaces disagreeing about one fact is worse
// than either being wrong on its own.
function verdictFor(site, cams) {
const gate = site.fraction_below_gate
const total = site.cameras_total || cams.length
if (!site.online) {
return { tone: 'bad', mark: '✕', headline: 'Offline',
words: `Offline — last heard from ${ago(site.last_heartbeat_at)}` }
}
// Footfall this shop saw and can never report. Stored with GREATEST()
// server-side so a restarted agent cannot make it quietly disappear.
if (site.dropped > 0) {
return { tone: 'bad', mark: '✕',
words: `${site.dropped} visits lost and unrecoverable` }
}
if (total === 0) {
return { tone: 'idle', mark: '+', headline: 'Not set up',
words: 'No cameras set up yet' }
}
if (site.cameras_up === 0) {
return { tone: 'bad', mark: '✕', words: 'No cameras connected' }
}
if (site.cameras_up < total) {
return { tone: 'warn', mark: '!',
words: `${total - site.cameras_up} of ${total} cameras not connecting` }
}
// The Office1 case: online, connected, and recognising almost nobody. Over
// half is a failure and not a warning — those visitors are gone.
if (gate > 0.5) {
return { tone: 'bad', mark: '✕',
words: `${pct(gate)} of faces too poor to recognise` }
}
if (gate > 0.2) {
return { tone: 'warn', mark: '!',
words: `${pct(gate)} of faces too poor to recognise` }
}
if (site.queued > 0) {
return { tone: 'warn', mark: '!', words: `${site.queued} visits waiting to upload` }
}
return { tone: 'ok', mark: '✓',
words: `Working — ${total} ${total === 1 ? 'camera' : 'cameras'} connected` }
}
const HEADLINES = { ok: 'Working', warn: 'Needs attention',
bad: 'Not working', idle: 'Not set up' }
function SiteCard({ site, cams, verdict, onCheck }) {
const gate = site.fraction_below_gate
const health = verdict.tone
const headline = verdict.headline || HEADLINES[health]
const view = bestView(cams)
const total = site.cameras_total || cams.length
return (
<article className={'card site state-' + health} onClick={onCheck} role="button"
tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
<div className="shot">
{view.url
? <Shot image={view} alt={`View inside ${site.name}`} />
: <div className="noshot">
<ShopMark />
{view.reason && <span>{view.reason}</span>}
</div>}
<div className="shot-over">
<div className="shot-name">
<b>{site.name}</b>
<span>{total > 0
? `${total} ${total === 1 ? 'camera' : 'cameras'}`
: 'No cameras yet'}</span>
</div>
<span className={'status ' + health}>
<i aria-hidden="true" />{headline}
</span>
</div>
{view.at && <span className="shot-age">{ago(view.at)}</span>}
</div>
{/* Three numbers, and each one is a different question: is the hardware
up, can it see faces well enough to recognise them, and is anyone
home. The rest moved behind the check. */}
<div className="metrics">
<Metric label="Cameras"
value={total ? `${site.cameras_up}/${total}` : '—'}
tone={!total ? 'idle' : site.cameras_up < total ? 'bad' : 'ok'} />
<Metric label="Faces usable"
value={gate > 0 ? pct(1 - gate) : '—'}
tone={!gate ? 'idle' : gate > 0.5 ? 'bad' : gate > 0.2 ? 'warn' : 'ok'} />
<Metric label="Last seen" value={ago(site.last_heartbeat_at)}
tone={site.online ? 'ok' : 'bad'} />
</div>
<div className={'verdict ' + verdict.tone}>
<span className="mark" aria-hidden="true">{verdict.mark}</span>
<span className="words">{verdict.words}</span>
<span className="go" aria-hidden="true">→</span>
</div>
</article>
)
}
function Metric({ label, value, tone }) {
return (
<div className="metric">
<b className={tone}>{value}</b>
<span>{label}</span>
</div>
)
}
// The freshest picture any of this shop's cameras has sent.
//
// A missing picture is a normal state, not an error — images are off by default
// across the product — so the empty tile explains itself rather than showing a
// black hole with an apology in it. The three absences need different words:
// "nobody has set a camera up", "the PC has not reported yet", and "this system
// stores no photographs" are three different next actions.
function bestView(cams) {
// Nothing written here for a shop with no cameras: the verdict line already
// says exactly that, and three phrasings of one fact on one card reads as a
// fault rather than a state.
if (!cams.length) return {}
let best = null
for (const c of cams) {
if (!c.snapshot?.available || !c.snapshot.url) continue
if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c
}
// The WHOLE snapshot object, not just its url. It carries `auth`, which says
// whether the picture has to be fetched with the session or can be handed
// straight to an <img> - and rebuilding a partial copy here is how that flag
// gets silently dropped on one screen and not another.
if (best) return { ...best.snapshot, at: best.snapshot_at }
const reason = cams.map(c => c.snapshot?.reason).find(Boolean)
return { reason: reason || 'No picture from this shop yet.' }
}
// Drawn, not an emoji or an icon font: an empty tile that still reads as a
// shop, in the same spirit as the camera screen's lens.
function ShopMark() {
return (
<svg className="shopmark" viewBox="0 0 40 32" aria-hidden="true">
<path d="M4 12h32v18H4z" />
<path d="M2 12l4-8h28l4 8" />
<path d="M15 30v-9h10v9" />
</svg>
)
}
function pct(f) { return `${Math.round(f * 100)}%` }
export function ago(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = Math.max(0, (Date.now() - then) / 1000)
if (secs < 90) return 'just now'
const mins = Math.round(secs / 60)
if (mins < 60) return `${mins} min ago`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `${hrs} h ago`
return `${Math.round(hrs / 24)} days ago`
}
// How long until a moment in the future.
//
// `ago` clamps at zero and reads a future timestamp as "just now", which is
// right for a heartbeat whose clock is a little ahead and completely wrong for
// an expiry: a code valid for a week rendered as "expires just now", which
// tells the operator not to bother handing it over.
export function until(iso) {
if (!iso) return 'never'
const then = new Date(iso).getTime()
if (Number.isNaN(then)) return '—'
const secs = (then - Date.now()) / 1000
if (secs <= 0) return 'expired'
const mins = Math.round(secs / 60)
if (mins < 60) return `in ${mins} min`
const hrs = Math.round(mins / 60)
if (hrs < 48) return `in ${hrs} h`
return `in ${Math.round(hrs / 24)} days`
}
export function Loading() {
return <div className="state"><span className="spinner" aria-hidden="true" />Loading…</div>
}
export function Problem({ error }) {
return (
<div className="state">
<p className="error" role="alert">{error.message}</p>
</div>
)
}
function Empty({ canOpen, onOpen }) {
return (
<div className="state">
<h2>No shops yet</h2>
<p className="sub">
{canOpen ? 'Open your first shop, then set up its PC and cameras from its card.'
: 'The owner opens shops; they appear here once created.'}
</p>
{canOpen && <button className="primary" onClick={onOpen}>Open a new shop</button>}
</div>
)
}