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
350 lines
14 KiB
JavaScript
350 lines
14 KiB
JavaScript
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>
|
||
)
|
||
}
|