feat(staff): time clock — clock on/off = shift = timecard

staff_shift table (one open shift per staff via partial unique index). /admin/clock
(status+today total), /clock/in (idempotent), /clock/out; /admin/timecards (admin)
= per-staff hours + recent shifts over a window. search.html top bar gets a live
clock widget (🟢 On 2h14m · Clock off · Log out; Clock on when off) ticking every 30s;
Log out warns if still clocked on. admin Staff tab gains a Timecards report.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-06-23 15:40:02 +10:00
parent 5e2a9e7ab1
commit b46930d468
4 changed files with 102 additions and 3 deletions

View File

@ -98,6 +98,68 @@ async def staff_reset_token(sid: int, ident=Depends(require_admin), db=Depends(g
return {"ok": True, "token": token}
# ── Time clock — staff clock on/off; the open shift + today's total drive the workstation widget ──
@router.get("/clock")
async def clock_status(ident=Depends(require_token), db=Depends(get_db)):
sid = ident.get("staff_id")
if not sid:
return {"staff": False}
cur = (await db.execute(text(
"SELECT id, clock_in FROM staff_shift WHERE staff_id=:s AND clock_out IS NULL ORDER BY clock_in DESC LIMIT 1"
), {"s": sid})).mappings().first()
today = (await db.execute(text("""
SELECT coalesce(sum(extract(epoch FROM (coalesce(clock_out, now()) - clock_in))), 0)::bigint
FROM staff_shift WHERE staff_id=:s AND clock_in >= date_trunc('day', now())"""), {"s": sid})).scalar()
return {"staff": True, "name": ident["name"], "open": dict(cur) if cur else None, "today_seconds": today}
@router.post("/clock/in")
async def clock_in(ident=Depends(require_token), db=Depends(get_db)):
sid = ident.get("staff_id")
if not sid:
raise HTTPException(400, "only staff clock in")
cur = (await db.execute(text(
"SELECT id FROM staff_shift WHERE staff_id=:s AND clock_out IS NULL LIMIT 1"), {"s": sid})).first()
if cur:
return {"ok": True, "already_open": True}
await db.execute(text("INSERT INTO staff_shift (staff_id) VALUES (:s)"), {"s": sid})
await db.commit()
return {"ok": True, "clocked_in": True}
@router.post("/clock/out")
async def clock_out(ident=Depends(require_token), db=Depends(get_db)):
sid = ident.get("staff_id")
if not sid:
raise HTTPException(400, "only staff clock out")
r = await db.execute(text(
"UPDATE staff_shift SET clock_out=now() WHERE staff_id=:s AND clock_out IS NULL"), {"s": sid})
await db.commit()
return {"ok": True, "closed": r.rowcount}
@router.get("/timecards")
async def timecards(days: int = Query(14), staff_id: int | None = None,
ident=Depends(require_admin), db=Depends(get_db)):
"""The timecard report (admin): per-staff hour totals + recent shifts over the window."""
where = ["sh.clock_in >= now() - make_interval(days => :d)"]
params = {"d": days}
if staff_id:
where.append("sh.staff_id = :sid"); params["sid"] = staff_id
w = " AND ".join(where)
shifts = [dict(r) for r in (await db.execute(text(f"""
SELECT sh.id, sh.staff_id, st.name, sh.clock_in, sh.clock_out,
round((extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in))/3600.0)::numeric, 2) AS hours
FROM staff_shift sh JOIN staff st ON st.id = sh.staff_id
WHERE {w} ORDER BY sh.clock_in DESC LIMIT 500"""), params)).mappings()]
totals = [dict(r) for r in (await db.execute(text(f"""
SELECT sh.staff_id, st.name, count(*) AS shifts,
round((sum(extract(epoch FROM (coalesce(sh.clock_out, now()) - sh.clock_in)))/3600.0)::numeric, 2) AS hours
FROM staff_shift sh JOIN staff st ON st.id = sh.staff_id
WHERE {w} GROUP BY sh.staff_id, st.name ORDER BY hours DESC"""), params)).mappings()]
return {"shifts": shifts, "totals": totals, "days": days}
@router.get("/inventory")
async def inventory(q: str = Query(""), kind: str = Query(""), crate_id: int | None = Query(None),
page: int = Query(1, ge=1), ident=Depends(require_token), db=Depends(get_db)):

View File

@ -45,6 +45,9 @@ _STARTUP_DDL = [
"CREATE TABLE IF NOT EXISTS store_config (store_id int PRIMARY KEY, config jsonb NOT NULL, updated_at timestamptz NOT NULL DEFAULT now())",
# staff accounts — each has a bearer token + role (admin sees API keys/connections, staff don't)
"CREATE TABLE IF NOT EXISTS staff (id bigserial PRIMARY KEY, name text NOT NULL, token text UNIQUE NOT NULL, role text NOT NULL DEFAULT 'staff', active boolean NOT NULL DEFAULT true, created_at timestamptz NOT NULL DEFAULT now(), last_seen timestamptz)",
# time clock — a shift = clock_in..clock_out; the partial unique index caps it at one open shift per staff
"CREATE TABLE IF NOT EXISTS staff_shift (id bigserial PRIMARY KEY, staff_id bigint NOT NULL, clock_in timestamptz NOT NULL DEFAULT now(), clock_out timestamptz, created_at timestamptz NOT NULL DEFAULT now())",
"CREATE UNIQUE INDEX IF NOT EXISTS staff_shift_one_open ON staff_shift (staff_id) WHERE clock_out IS NULL",
# public 'request a record we don't stock' intake (storefront wantlist; email-keyed, guest or customer)
"CREATE TABLE IF NOT EXISTS wantlist (id bigserial PRIMARY KEY, release_id int, artist text NOT NULL DEFAULT '', title text NOT NULL DEFAULT '', name text, phone text, email text NOT NULL, format text, max_price numeric(10,2), delivery_preference text DEFAULT 'either', postcode text, notes text, status text NOT NULL DEFAULT 'pending', created_at timestamptz NOT NULL DEFAULT now(), actioned_at timestamptz)",
"CREATE INDEX IF NOT EXISTS idx_wantlist_status ON wantlist (status, created_at DESC)",

View File

@ -426,8 +426,19 @@ async function vStaff(){
$('#content').innerHTML = '<h2>Staff</h2>'
+ '<div class="muted">Operators get a sign-in token + role. Staff can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.</div>'
+ '<div class="card" style="margin-top:12px"><h3>Add staff</h3><div class="bar"><input id="stName" placeholder="name"><select id="stRole"><option value="staff">Staff</option><option value="admin">Admin</option></select><button onclick="staffAdd()">Add</button></div><div id="stNew" style="margin-top:10px"></div></div>'
+ '<div id="staffRows" class="muted" style="margin-top:12px">loading…</div>';
loadStaff();
+ '<div id="staffRows" class="muted" style="margin-top:12px">loading…</div>'
+ '<div id="timecards" class="muted" style="margin-top:18px">loading hours…</div>';
loadStaff(); loadTimecards();
}
const fmtDT = s => s ? new Date(s).toLocaleString(undefined,{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
async function loadTimecards(){
const d = await api('/timecards?days=14');
const tot = (d.totals||[]).map(t=>`<tr><td>${escapeH(t.name)}</td><td>${t.shifts}</td><td class="price"><b>${t.hours}</b> h</td></tr>`).join('');
const sh = (d.shifts||[]).map(s=>`<tr><td>${escapeH(s.name)}</td><td class="muted">${fmtDT(s.clock_in)}</td><td>${s.clock_out?'<span class="muted">'+fmtDT(s.clock_out)+'</span>':'<span style="color:var(--ok)">● on shift</span>'}</td><td>${s.hours} h</td></tr>`).join('');
$('#timecards').innerHTML = '<h3>Timecards — last 14 days</h3>'
+ `<table><thead><tr><th>Staff</th><th>Shifts</th><th>Hours</th></tr></thead><tbody>${tot||'<tr><td colspan=3 class=muted>no shifts yet</td></tr>'}</tbody></table>`
+ '<h3 style="margin-top:14px">Recent shifts</h3>'
+ `<table><thead><tr><th>Staff</th><th>Clock in</th><th>Clock out</th><th>Hours</th></tr></thead><tbody>${sh||'<tr><td colspan=4 class=muted>none</td></tr>'}</tbody></table>`;
}
async function loadStaff(){
const d = await api('/staff');

View File

@ -67,6 +67,7 @@
<h1>Search &amp; <b>Inventory</b></h1>
<input id="omni" class="omni" placeholder="Scan or type — release ID, SKU, c12 (crate), r3 (rack)" autocomplete="off">
<button class="prim" onclick="omni()">🔍 Go</button>
<span id="clockw" style="display:flex;gap:6px;align-items:center"></span>
</div>
<div class="tabs" id="tabs">
@ -168,7 +169,29 @@ async function signin(){
if(!(await fetch('/admin/stats',{headers:hdr()})).ok){ $('#gerr').textContent='invalid token'; return; }
localStorage.setItem('rg_token',TOKEN);
$('#gate').style.display='none'; $('#app').style.display='block';
await loadSpaces(); toStore(); setTab('scanner'); $('#omni').focus();
await loadSpaces(); toStore(); setTab('scanner'); $('#omni').focus(); loadClock();
}
// ───────────── time clock (clock on for your shift) ─────────────
let CLOCK={staff:false,open:null,today:0};
const fmtDur=s=>{ s=Math.max(0,s|0); const h=Math.floor(s/3600),m=Math.floor((s%3600)/60); return h?`${h}h ${m}m`:`${m}m`; };
async function loadClock(){ const d=await get('/admin/clock'); CLOCK={staff:d.staff,open:d.open,today:d.today_seconds||0}; drawClock(); }
function drawClock(){
const el=$('#clockw'); if(!el) return;
if(!CLOCK.staff){ el.innerHTML='<button class="ghost" onclick="logout()">Log out</button>'; return; } // owner
if(CLOCK.open){
const sec=(Date.now()-new Date(CLOCK.open.clock_in).getTime())/1000;
el.innerHTML=`<span class="ok" style="font-weight:600">🟢 On ${fmtDur(sec)}</span><button class="ghost" onclick="clockOut()">Clock off</button><button class="ghost" onclick="logout()">Log out</button>`;
} else {
el.innerHTML=`<button class="prim" onclick="clockIn()">🟢 Clock on</button><button class="ghost" onclick="logout()">Log out</button>`;
}
}
setInterval(()=>{ if(CLOCK.open) drawClock(); }, 30000); // tick the elapsed time
async function clockIn(){ await post('/admin/clock/in',{}); loadClock(); }
async function clockOut(){ if(!confirm('Clock off — end your shift?')) return; await post('/admin/clock/out',{}); loadClock(); }
function logout(){
if(CLOCK.open && !confirm('You are still clocked ON. Log out without clocking off?')) return;
localStorage.removeItem('rg_token'); location.reload();
}
async function loadSpaces(){
const d=await get('/nav/spaces');