Compare commits

...

3 Commits

Author SHA1 Message Date
70c7b9564b feat(manager-app): Part 5 — show snapshot_taken_at freshness in DashboardPage
Adds a "Site data: X min ago" line derived from snapshot.snapshot_taken_at
so managers can see how fresh the stats snapshot is.

All other Part 5 items (menu-app api.js, OrderConfirm polling, manager-app
api.js, IncomingOrdersPage 15s poll + OrderCard actions) were already fully
implemented in the existing scaffolding.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:10:08 +03:00
82b853369b feat(sysadmin): Part 4 — QR code section in SiteDetailPage
- Install qrcode.react in sysadmin_panel
- Add QR Codes section above Remote Managers with two codes side by side:
  Menu QR (https://yourdomain.com/menu/{site_id}) and
  Order QR (https://yourdomain.com/menu/{site_id}/order)
- Each card shows the URL as copyable text and a Download PNG button
- Uses site.site_id as the slug (no slug field exists on the Site model)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:07:28 +03:00
ac58d150ea feat(sysadmin): Part 3 — Remote Managers section in SiteDetailPage
Backend (manager_auth.py):
- register: upsert instead of 409 — if email exists, just add site access
- GET /api/manager/by-site/{site_id}: list managers linked to a site (admin auth)
- DELETE /api/manager/site-access: remove a manager's access to a site (admin auth)

Sysadmin API client:
- getManagersBySite, addManagerToSite, removeManagerSiteAccess

SiteDetailPage.jsx:
- Remote Managers section: lists all linked managers with email, name, active badge
- Add Manager modal: email + full name + password form (password ignored if account exists)
- Remove access button with optimistic removal from list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 10:04:28 +03:00
6 changed files with 289 additions and 8 deletions

View File

@@ -1,13 +1,15 @@
from datetime import datetime, timedelta, timezone
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from passlib.context import CryptContext
from pydantic import BaseModel
from sqlalchemy.orm import Session
from config import settings
from database import get_db
from models.manager_account import ManagerAccount
from models.manager_account import ManagerAccount, manager_site_access
from models.site import Site
from auth_utils import get_current_admin
from schemas.manager import (
@@ -56,10 +58,18 @@ def register_manager(
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
if db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
existing = db.query(ManagerAccount).filter(ManagerAccount.email == body.email).first()
sites = db.query(Site).filter(Site.id.in_(body.site_ids)).all()
if existing:
# Email already exists — just add the new site access links, don't recreate the account
for site in sites:
if site not in existing.sites:
existing.sites.append(site)
db.commit()
db.refresh(existing)
return existing
manager = ManagerAccount(
email=body.email,
password_hash=_pwd.hash(body.password),
@@ -89,3 +99,52 @@ def login_manager(body: ManagerLoginRequest, db: Session = Depends(get_db)):
@router.post("/refresh", response_model=ManagerTokenOut)
def refresh_manager_token(manager: ManagerAccount = Depends(_get_current_manager)):
return ManagerTokenOut(access_token=_create_manager_token(manager))
# ── Admin-only: list managers for a site ─────────────────────────────────────
class ManagerBySiteOut(BaseModel):
id: int
email: str
full_name: Optional[str] = None
is_active: int
model_config = {"from_attributes": True}
@router.get("/by-site/{site_id}", response_model=list[ManagerBySiteOut])
def get_managers_by_site(
site_id: int,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
site = db.query(Site).filter(Site.id == site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site.manager_accounts
# ── Admin-only: remove a manager's access to a site ──────────────────────────
class SiteAccessRemoveRequest(BaseModel):
manager_id: int
site_id: int
@router.delete("/site-access", status_code=status.HTTP_204_NO_CONTENT)
def remove_manager_site_access(
body: SiteAccessRemoveRequest,
db: Session = Depends(get_db),
_admin=Depends(get_current_admin),
):
manager = db.query(ManagerAccount).filter(ManagerAccount.id == body.manager_id).first()
if not manager:
raise HTTPException(status_code=404, detail="Manager not found")
site = db.query(Site).filter(Site.id == body.site_id).first()
if not site:
raise HTTPException(status_code=404, detail="Site not found")
if site in manager.sites:
manager.sites.remove(site)
db.commit()

View File

@@ -64,10 +64,20 @@ export default function DashboardPage() {
{lastUpdated && (
<p className="text-xs text-slate-400 text-right">
Updated {lastUpdated.toLocaleTimeString()}
Refreshed {lastUpdated.toLocaleTimeString()}
</p>
)}
{stats.snapshot_taken_at && (() => {
const diffMin = Math.round((Date.now() - new Date(stats.snapshot_taken_at).getTime()) / 60_000)
const label = diffMin < 1 ? 'just now' : `${diffMin} min ago`
return (
<p className="text-xs text-slate-400 text-right -mt-3">
Site data: {label}
</p>
)
})()}
{/* Pending orders */}
<div className="space-y-3">
<div className="flex items-center justify-between">

View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"axios": "^1.7.9",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",
@@ -2472,6 +2473,15 @@
"node": ">=10"
}
},
"node_modules/qrcode.react": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz",
"integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==",
"license": "ISC",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"axios": "^1.7.9",
"qrcode.react": "^4.2.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-hot-toast": "^2.4.1",

View File

@@ -22,3 +22,16 @@ client.interceptors.response.use(
)
export default client
// ── Remote Manager accounts ───────────────────────────────────────────────────
export const getManagersBySite = (siteId) =>
client.get(`/api/manager/by-site/${siteId}`)
export const addManagerToSite = (email, fullName, password, siteId) =>
client.post('/api/manager/register', {
email, full_name: fullName, password, site_ids: [siteId],
})
export const removeManagerSiteAccess = (managerId, siteId) =>
client.delete('/api/manager/site-access', { data: { manager_id: managerId, site_id: siteId } })

View File

@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import toast from 'react-hot-toast'
import client from '../api/client'
import { QRCodeCanvas } from 'qrcode.react'
import client, { getManagersBySite, addManagerToSite, removeManagerSiteAccess } from '../api/client'
import LicenseStatus from '../components/LicenseStatus'
import ConfirmModal from '../components/ConfirmModal'
@@ -26,12 +27,21 @@ export default function SiteDetailPage() {
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain'
const [modal, setModal] = useState(null) // 'lock' | 'unlock' | 'delete' | 'license' | 'domain' | 'add_manager'
const [lockReason, setLockReason] = useState('')
const [newExpiry, setNewExpiry] = useState('')
const [newDomain, setNewDomain] = useState('')
const [acting, setActing] = useState(false)
// Remote Managers state
const [managers, setManagers] = useState([])
const [managersLoading, setManagersLoading] = useState(false)
const [newMgrEmail, setNewMgrEmail] = useState('')
const [newMgrName, setNewMgrName] = useState('')
const [newMgrPass, setNewMgrPass] = useState('')
const [addingMgr, setAddingMgr] = useState(false)
const [removingMgrId, setRemovingMgrId] = useState(null)
useEffect(() => {
async function load() {
try {
@@ -47,6 +57,49 @@ export default function SiteDetailPage() {
load()
}, [siteId])
const fetchManagers = useCallback(async () => {
setManagersLoading(true)
try {
const { data } = await getManagersBySite(siteId)
setManagers(data)
} catch {
toast.error('Failed to load managers')
} finally {
setManagersLoading(false)
}
}, [siteId])
useEffect(() => { fetchManagers() }, [fetchManagers])
async function doAddManager() {
if (!newMgrEmail.trim() || !newMgrPass.trim()) return
setAddingMgr(true)
try {
await addManagerToSite(newMgrEmail.trim(), newMgrName.trim(), newMgrPass.trim(), Number(siteId))
toast.success('Manager added')
setModal(null)
setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('')
fetchManagers()
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to add manager')
} finally {
setAddingMgr(false)
}
}
async function doRemoveManager(managerId) {
setRemovingMgrId(managerId)
try {
await removeManagerSiteAccess(managerId, Number(siteId))
toast.success('Access removed')
setManagers(prev => prev.filter(m => m.id !== managerId))
} catch (e) {
toast.error(e.response?.data?.detail || 'Failed to remove access')
} finally {
setRemovingMgrId(null)
}
}
async function doLock() {
if (!lockReason.trim()) return
setActing(true)
@@ -270,6 +323,99 @@ export default function SiteDetailPage() {
</div>
</div>
{/* QR Codes */}
{(() => {
const slug = site.site_id
const menuUrl = `https://yourdomain.com/menu/${slug}`
const orderUrl = `https://yourdomain.com/menu/${slug}/order`
function handleDownload(canvasId, filename) {
const canvas = document.getElementById(canvasId)
if (!canvas) return
const url = canvas.toDataURL('image/png')
const a = document.createElement('a')
a.href = url
a.download = filename
a.click()
}
return (
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-4">QR Codes</h2>
<div className="flex flex-wrap gap-8">
{[
{ id: `qr-menu-${site.id}`, url: menuUrl, label: 'Menu QR Code', file: `menu-qr-${slug}.png` },
{ id: `qr-order-${site.id}`, url: orderUrl, label: 'Order QR Code', file: `order-qr-${slug}.png` },
].map(({ id, url, label, file }) => (
<div key={id} className="flex flex-col items-center gap-3">
<p className="text-xs font-medium text-gray-400">{label}</p>
<div className="bg-white p-3 rounded-xl">
<QRCodeCanvas id={id} value={url} size={180} includeMargin={false} />
</div>
<p className="text-xs text-gray-600 font-mono break-all max-w-[220px] text-center">{url}</p>
<button
onClick={() => handleDownload(id, file)}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
Download PNG
</button>
</div>
))}
</div>
</div>
)
})()}
{/* Remote Managers */}
<div className="bg-gray-900 border border-gray-700 rounded-xl p-4 mt-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Remote Managers</h2>
<button
onClick={() => setModal('add_manager')}
className="text-xs text-cyan-400 hover:text-cyan-300 transition-colors"
>
+ Add Manager
</button>
</div>
{managersLoading && (
<p className="text-xs text-gray-500">Loading</p>
)}
{!managersLoading && managers.length === 0 && (
<p className="text-xs text-gray-600 italic">No managers linked to this site.</p>
)}
{!managersLoading && managers.length > 0 && (
<div className="space-y-2">
{managers.map(mgr => (
<div key={mgr.id} className="flex items-center justify-between gap-3 bg-gray-800 rounded-lg px-3 py-2">
<div className="min-w-0">
<p className="text-sm text-gray-200 font-medium truncate">{mgr.email}</p>
{mgr.full_name && (
<p className="text-xs text-gray-500 truncate">{mgr.full_name}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-xs font-semibold px-2 py-0.5 rounded-full ${
mgr.is_active ? 'bg-emerald-900/50 text-emerald-400' : 'bg-gray-700 text-gray-500'
}`}>
{mgr.is_active ? 'Active' : 'Inactive'}
</span>
<button
onClick={() => doRemoveManager(mgr.id)}
disabled={removingMgrId === mgr.id}
className="text-xs text-red-400 hover:text-red-300 disabled:opacity-50 transition-colors"
>
{removingMgrId === mgr.id ? '…' : 'Remove'}
</button>
</div>
</div>
))}
</div>
)}
</div>
{/* Modals */}
{modal === 'lock' && (
<ConfirmModal
@@ -349,6 +495,48 @@ export default function SiteDetailPage() {
</div>
</ConfirmModal>
)}
{modal === 'add_manager' && (
<ConfirmModal
title="Add Remote Manager"
confirmLabel={addingMgr ? 'Adding…' : 'Add Manager'}
onCancel={() => { setModal(null); setNewMgrEmail(''); setNewMgrName(''); setNewMgrPass('') }}
onConfirm={doAddManager}
>
<div className="space-y-3 mb-2">
<div>
<label className="block text-xs text-gray-400 mb-1.5">Email *</label>
<input
type="email"
value={newMgrEmail}
onChange={e => setNewMgrEmail(e.target.value)}
placeholder="manager@example.com"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Full name</label>
<input
type="text"
value={newMgrName}
onChange={e => setNewMgrName(e.target.value)}
placeholder="Jane Smith"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1.5">Password * <span className="text-gray-600">(ignored if account already exists)</span></label>
<input
type="password"
value={newMgrPass}
onChange={e => setNewMgrPass(e.target.value)}
placeholder="••••••••"
className="w-full bg-gray-800 border border-gray-600 text-white text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-1 focus:ring-cyan-500 placeholder-gray-600"
/>
</div>
</div>
</ConfirmModal>
)}
</div>
)
}