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 if (error) return if (!sites.length && !opening) return 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 ( <>

Shops

{sites.length} {sites.length === 1 ? 'shop' : 'shops'} {broken > 0 && <> · {broken} not working} {watch > 0 && <> · {watch} needing attention} {fresh > 0 && <> · {fresh} not set up yet} {!broken && !watch && !fresh && <> · all working}

{canOpen && ( )}
{rows.map(r => ( setChecking(r.site)} /> ))}
{/* 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 && setChecking(null)} onChanged={reload} />} {opening && 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 (
) } // 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 (
e.key === 'Enter' && onCheck()}>
{view.url ? :
{view.reason && {view.reason}}
}
{site.name} {total > 0 ? `${total} ${total === 1 ? 'camera' : 'cameras'}` : 'No cameras yet'}