diff --git a/app/admin_routes.py b/app/admin_routes.py index f41e58c..7976dc2 100644 --- a/app/admin_routes.py +++ b/app/admin_routes.py @@ -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)): diff --git a/app/main.py b/app/main.py index 533f5c1..62b2298 100644 --- a/app/main.py +++ b/app/main.py @@ -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)", diff --git a/site/admin.html b/site/admin.html index 0935215..62bc6fc 100644 --- a/site/admin.html +++ b/site/admin.html @@ -426,8 +426,19 @@ async function vStaff(){ $('#content').innerHTML = '

Staff

' + '
Operators get a sign-in token + role. Staff can run the store (search, scan, inventory, prices, labels) but can\'t see API keys / connections.
' + '

Add staff

' - + '
loading…
'; - loadStaff(); + + '
loading…
' + + '
loading hours…
'; + 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=>`${escapeH(t.name)}${t.shifts}${t.hours} h`).join(''); + const sh = (d.shifts||[]).map(s=>`${escapeH(s.name)}${fmtDT(s.clock_in)}${s.clock_out?''+fmtDT(s.clock_out)+'':'● on shift'}${s.hours} h`).join(''); + $('#timecards').innerHTML = '

Timecards — last 14 days

' + + `${tot||''}
StaffShiftsHours
no shifts yet
` + + '

Recent shifts

' + + `${sh||''}
StaffClock inClock outHours
none
`; } async function loadStaff(){ const d = await api('/staff'); diff --git a/site/search.html b/site/search.html index bbae63e..8ba77e2 100644 --- a/site/search.html +++ b/site/search.html @@ -67,6 +67,7 @@

Search & Inventory

+
@@ -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=''; return; } // owner + if(CLOCK.open){ + const sec=(Date.now()-new Date(CLOCK.open.clock_in).getTime())/1000; + el.innerHTML=`🟢 On ${fmtDur(sec)}`; + } else { + el.innerHTML=``; + } +} +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');