Compare commits
8 Commits
ae6685a401
...
7024b5f820
| Author | SHA1 | Date | |
|---|---|---|---|
| 7024b5f820 | |||
| 4988fdaa21 | |||
| a3328c765d | |||
| 2cafe6879a | |||
| 6a37ea9f77 | |||
| 6bf8a3e970 | |||
| 510bafc919 | |||
| 605353c533 |
+3
-5
@@ -1,9 +1,7 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
assets.db
|
||||
uploads/
|
||||
.venv/
|
||||
*.db
|
||||
*.db.real
|
||||
*.xlsx
|
||||
admin.db
|
||||
canteen_admin.db
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# Canteen Admin Server — Agent Guide
|
||||
|
||||
Admin-only FastAPI backend sharing the Canteen Asset Tracker SQLite DB. CRUD for users, customers, locations, rooms, geofences, settings, plus Cantaloupe sync pipeline.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Python** 3.11+, **FastAPI**, **uvicorn**
|
||||
- **openpyxl** — Excel export generation
|
||||
- **httpx** — Cantaloupe sync HTTP calls
|
||||
- **pytest** — test suite
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
admin_server.py # ~1.7K lines — ALL admin routes in one file
|
||||
cantaloupe_sync.py # APIRouter at /api/admin/cantaloupe — staged import pipeline
|
||||
run.sh # Launches uvicorn admin_server:app --reload on port 8090
|
||||
requirements.txt
|
||||
scripts/
|
||||
cantaloupe-sync.sh # Cron script: export Cantaloupe → POST to sync API
|
||||
static/
|
||||
index.html # Admin SPA frontend
|
||||
test_admin_crud.py # Admin CRUD tests
|
||||
test_cantaloupe_sync.py # Sync pipeline tests
|
||||
test_sync_api.py # Sync API endpoint tests
|
||||
```
|
||||
|
||||
## How to Run
|
||||
|
||||
```bash
|
||||
# Dev (with --reload)
|
||||
./run.sh
|
||||
|
||||
# Prod
|
||||
uvicorn admin_server:app --host 0.0.0.0 --port 8090
|
||||
```
|
||||
|
||||
## Key Architecture
|
||||
|
||||
- Shares `assets.db` with `canteen-asset-tracker` via `CANTEEN_DB_PATH` env var (defaults to `../canteen-asset-tracker/assets.db`)
|
||||
- Auth: Bearer tokens, admin role required for all routes
|
||||
- All routes under `/api/admin/`
|
||||
- Sync pipeline: upload Excel → parse → diff → approve/reject
|
||||
|
||||
## Production
|
||||
|
||||
- **URL:** https://admin.canteen.ourpad.casa
|
||||
- **Port:** 8090 (NPMPlus reverse proxy)
|
||||
- **No systemd service** — runs via shell script
|
||||
- **DB:** Shares SQLite with canteen-asset-tracker (WAL mode, foreign keys)
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Var | Default | Description |
|
||||
|-----|---------|-------------|
|
||||
| CANTEEN_DB_PATH | `../canteen-asset-tracker/assets.db` | Path to shared SQLite DB |
|
||||
| CANTALOUPE_EMAIL | — | Cantaloupe login email |
|
||||
| CANTALOUPE_PASSWORD | — | Cantaloupe login password |
|
||||
| EXPORTS_DIR | `~/cantaloupe-exports` | Excel download directory |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Single-file backend** — admin_server.py is ~1.7K lines. Routes grouped by entity but not split into modules.
|
||||
- **DB locking** — Shares `assets.db` with canteen-asset-tracker. Both use WAL mode so concurrent reads work, but schema migrations need coordination.
|
||||
- **Sync pipeline** — `cantaloupe_sync.py` is mounted as APIRouter on the same uvicorn instance; both run in one process.
|
||||
+549
-29
@@ -310,11 +310,8 @@ def _seed_if_empty(conn: sqlite3.Connection, table: str, columns: tuple, rows: l
|
||||
def _seed_data(conn: sqlite3.Connection):
|
||||
"""Insert default seed data for lookup tables."""
|
||||
_seed_if_empty(conn, "categories", ("name", "icon"), [
|
||||
("Bev", "🥤"), ("Snack", "🍿"),
|
||||
("Food", "🍔"), ("Equipment", "🔧"),
|
||||
("Appliances", "🔌"), ("Kiosk", "🏪"),
|
||||
("Coffee", "☕"),
|
||||
("Other", "📦"),
|
||||
("Furniture", "🪑"), ("Appliances", "🔌"),
|
||||
("Utensils & Serveware", "🍽️"), ("Equipment", "⚙️"), ("Other", "📦"),
|
||||
])
|
||||
_seed_if_empty(conn, "key_names", ("name",), [
|
||||
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
|
||||
@@ -409,7 +406,7 @@ async def auth_middleware(request: Request, call_next):
|
||||
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
||||
return await call_next(request)
|
||||
|
||||
if not path.startswith("/api/") or path == "/api/auth/login":
|
||||
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
|
||||
return await call_next(request)
|
||||
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
@@ -759,6 +756,9 @@ def update_user(user_id: int, body: UserUpdate):
|
||||
if body.role is not None:
|
||||
role = validate_roles(body.role)
|
||||
conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
|
||||
_log_activity(conn, "updated", "user", user_id,
|
||||
f"User '{existing['username']}' role changed to '{role}'")
|
||||
conn.commit()
|
||||
|
||||
if body.password is not None:
|
||||
pw = body.password.strip()
|
||||
@@ -767,6 +767,9 @@ def update_user(user_id: int, body: UserUpdate):
|
||||
raise HTTPException(status_code=422, detail="Password must not be empty")
|
||||
password_hash = _hash_password(pw)
|
||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
|
||||
_log_activity(conn, "updated", "user", user_id,
|
||||
f"User '{existing['username']}' password changed")
|
||||
conn.commit()
|
||||
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
@@ -777,12 +780,15 @@ def update_user(user_id: int, body: UserUpdate):
|
||||
@app.delete("/api/users/{user_id}", status_code=204)
|
||||
def delete_user(user_id: int):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
existing = conn.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||
conn.commit()
|
||||
_log_activity(conn, "deleted", "user", user_id,
|
||||
f"User '{existing['username']}' deleted")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -832,6 +838,9 @@ def login(body: dict):
|
||||
(row["id"], token),
|
||||
)
|
||||
conn.commit()
|
||||
_log_activity(conn, "login", "user", row["id"],
|
||||
f"User '{username}' logged in")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
result = _user_to_dict(row)
|
||||
@@ -995,6 +1004,9 @@ def create_location(body: LocationCreate):
|
||||
)
|
||||
conn.commit()
|
||||
loc_id = cursor.lastrowid
|
||||
_log_activity(conn, "created", "location", loc_id,
|
||||
f"Location '{name}' created")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||
result = row_to_dict(row)
|
||||
result["rooms"] = []
|
||||
@@ -1044,7 +1056,7 @@ _LOCATION_FIELDS = [
|
||||
@app.put("/api/locations/{loc_id}")
|
||||
def update_location(loc_id: int, body: LocationUpdate):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
@@ -1080,6 +1092,9 @@ def update_location(loc_id: int, body: LocationUpdate):
|
||||
values + [loc_id],
|
||||
)
|
||||
conn.commit()
|
||||
_log_activity(conn, "updated", "location", loc_id,
|
||||
f"Location '{existing['name']}' updated")
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||
result = row_to_dict(row)
|
||||
@@ -1091,12 +1106,15 @@ def update_location(loc_id: int, body: LocationUpdate):
|
||||
@app.delete("/api/locations/{loc_id}", status_code=204)
|
||||
def delete_location(loc_id: int):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Location not found")
|
||||
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
||||
conn.commit()
|
||||
_log_activity(conn, "deleted", "location", loc_id,
|
||||
f"Location '{existing['name']}' deleted")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -1138,6 +1156,9 @@ def create_room(body: RoomCreate):
|
||||
)
|
||||
conn.commit()
|
||||
room_id = cursor.lastrowid
|
||||
_log_activity(conn, "created", "room", room_id,
|
||||
f"Room '{body.name.strip()}' created in location {body.location_id}")
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||
conn.close()
|
||||
return row_to_dict(row)
|
||||
@@ -1146,7 +1167,7 @@ def create_room(body: RoomCreate):
|
||||
@app.put("/api/rooms/{room_id}")
|
||||
def update_room(room_id: int, body: RoomUpdate):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
@@ -1172,6 +1193,9 @@ def update_room(room_id: int, body: RoomUpdate):
|
||||
values + [room_id],
|
||||
)
|
||||
conn.commit()
|
||||
_log_activity(conn, "updated", "room", room_id,
|
||||
f"Room '{existing['name']}' updated")
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||
conn.close()
|
||||
@@ -1181,12 +1205,15 @@ def update_room(room_id: int, body: RoomUpdate):
|
||||
@app.delete("/api/rooms/{room_id}", status_code=204)
|
||||
def delete_room(room_id: int):
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Room not found")
|
||||
conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,))
|
||||
conn.commit()
|
||||
_log_activity(conn, "deleted", "room", room_id,
|
||||
f"Room '{existing['name']}' deleted")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -1229,6 +1256,9 @@ def _settings_create(conn: sqlite3.Connection, entity: str, body: dict):
|
||||
)
|
||||
conn.commit()
|
||||
eid = cursor.lastrowid
|
||||
_log_activity(conn, "created", entity, eid,
|
||||
f"{entity[:-1].title()} '{body.get('name', '')}' created")
|
||||
conn.commit()
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
except sqlite3.IntegrityError:
|
||||
@@ -1240,7 +1270,7 @@ def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict
|
||||
table = schema["table"]
|
||||
columns = schema["columns"]
|
||||
|
||||
existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
||||
|
||||
@@ -1256,6 +1286,9 @@ def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict
|
||||
f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid]
|
||||
)
|
||||
conn.commit()
|
||||
_log_activity(conn, "updated", entity, eid,
|
||||
f"{entity[:-1].title()} '{existing['name']}' updated")
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||
return row_to_dict(row)
|
||||
@@ -1273,11 +1306,14 @@ def _settings_get(conn: sqlite3.Connection, entity: str, eid: int):
|
||||
def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int):
|
||||
schema = _SETTINGS_SCHEMAS[entity]
|
||||
table = schema["table"]
|
||||
existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||
if existing is None:
|
||||
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
||||
conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,))
|
||||
conn.commit()
|
||||
_log_activity(conn, "deleted", entity, eid,
|
||||
f"{entity[:-1].title()} '{existing['name']}' deleted")
|
||||
conn.commit()
|
||||
|
||||
|
||||
_SETTINGS_ENTITIES = ["categories", "makes", "models", "key_names", "key_types", "badge_types"]
|
||||
@@ -1379,7 +1415,7 @@ def list_geofences():
|
||||
def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
||||
conn = get_db()
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM geofences WHERE id = ?", (geofence_id,)
|
||||
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
@@ -1410,6 +1446,10 @@ def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
||||
_sync_geofence_users(conn, geofence_id, body.user_ids)
|
||||
conn.commit()
|
||||
|
||||
_log_activity(conn, "updated", "geofence", geofence_id,
|
||||
f"Geofence '{existing['name']}' updated")
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT * FROM geofences WHERE id = ?", (geofence_id,)
|
||||
).fetchone()
|
||||
@@ -1422,13 +1462,16 @@ def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
||||
def delete_geofence(geofence_id: int):
|
||||
conn = get_db()
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM geofences WHERE id = ?", (geofence_id,)
|
||||
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
|
||||
).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Geofence not found")
|
||||
conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,))
|
||||
conn.commit()
|
||||
_log_activity(conn, "deleted", "geofence", geofence_id,
|
||||
f"Geofence '{existing['name']}' deleted")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
@@ -1529,19 +1572,6 @@ def get_stats():
|
||||
).fetchall()
|
||||
by_make = {r["make"]: r["cnt"] for r in makes}
|
||||
|
||||
# Find asset categories not in the categories lookup table
|
||||
unmapped = conn.execute(
|
||||
"""SELECT a.category AS name, COUNT(*) AS cnt
|
||||
FROM assets a LEFT JOIN categories c ON a.category = c.name
|
||||
WHERE c.id IS NULL AND a.category IS NOT NULL AND a.category != ''
|
||||
GROUP BY a.category ORDER BY cnt DESC"""
|
||||
).fetchall()
|
||||
category_health = {
|
||||
"unmapped": [{"name": r["name"], "count": r["cnt"]} for r in unmapped],
|
||||
"total_categories": len(by_category),
|
||||
"unmapped_count": len(unmapped),
|
||||
}
|
||||
|
||||
conn.close()
|
||||
return {
|
||||
"total_assets": total_assets,
|
||||
@@ -1554,7 +1584,6 @@ def get_stats():
|
||||
"top_visited": top_visited_list,
|
||||
"time_on_site": time_on_site,
|
||||
"by_make": by_make,
|
||||
"category_health": category_health,
|
||||
}
|
||||
|
||||
|
||||
@@ -1624,6 +1653,119 @@ def export_checkins_csv(asset_id: Optional[int] = Query(None)):
|
||||
# ─── Asset GPS Management ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/assets")
|
||||
def list_assets_admin(
|
||||
q: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
category: Optional[str] = Query(None),
|
||||
customer_id: Optional[int] = Query(None),
|
||||
limit: int = Query(500, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List all assets with optional filters and search."""
|
||||
conn = get_db()
|
||||
conditions = []
|
||||
params = []
|
||||
|
||||
if q:
|
||||
conditions.append("(name LIKE ? OR machine_id LIKE ? OR company LIKE ? OR serial_number LIKE ?)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like, like, like])
|
||||
if status:
|
||||
conditions.append("status = ?")
|
||||
params.append(status)
|
||||
if category:
|
||||
conditions.append("category = ?")
|
||||
params.append(category)
|
||||
if customer_id is not None:
|
||||
conditions.append("customer_id = ?")
|
||||
params.append(customer_id)
|
||||
|
||||
where = " AND ".join(conditions)
|
||||
sql = "SELECT * FROM assets"
|
||||
count_sql = "SELECT COUNT(*) FROM assets"
|
||||
if where:
|
||||
sql += f" WHERE {where}"
|
||||
count_sql += f" WHERE {where}"
|
||||
|
||||
total = conn.execute(count_sql, params).fetchone()[0]
|
||||
sql += " ORDER BY name ASC LIMIT ? OFFSET ?"
|
||||
rows = conn.execute(sql, params + [limit, offset]).fetchall()
|
||||
conn.close()
|
||||
|
||||
result = [row_to_dict(r) for r in rows]
|
||||
return JSONResponse({
|
||||
"assets": result,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
})
|
||||
|
||||
|
||||
@app.put("/api/assets/{asset_id}")
|
||||
def update_asset_admin(asset_id: int, body: dict):
|
||||
"""Update any fields on an asset (admin). Body can include any asset column."""
|
||||
conn = get_db()
|
||||
existing = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
||||
if existing is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="Asset not found")
|
||||
|
||||
# Whitelist of editable fields
|
||||
EDITABLE_FIELDS = {
|
||||
"name", "machine_id", "serial_number", "description", "category", "status",
|
||||
"make", "model", "address", "building_name", "building_number", "floor", "room",
|
||||
"trailer_number", "walking_directions", "map_link", "parking_location",
|
||||
"company", "place", "location_area",
|
||||
"install_date", "dex_report_date", "pulled_date", "deployed",
|
||||
"customer_id", "location_id", "assigned_to",
|
||||
"latitude", "longitude", "geofence_radius_meters",
|
||||
}
|
||||
|
||||
updates = {}
|
||||
for field, val in body.items():
|
||||
if field not in EDITABLE_FIELDS:
|
||||
continue
|
||||
if val is not None:
|
||||
updates[field] = val
|
||||
else:
|
||||
updates[field] = None
|
||||
|
||||
if not updates:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=422, detail="No valid fields to update")
|
||||
|
||||
if "category" in updates and updates["category"]:
|
||||
row = conn.execute("SELECT id FROM categories WHERE name = ?", (updates["category"],)).fetchone()
|
||||
if row is None:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=422, detail=f"Invalid category '{updates['category']}'")
|
||||
|
||||
if "status" in updates and updates["status"]:
|
||||
if updates["status"] not in VALID_STATUSES:
|
||||
conn.close()
|
||||
raise HTTPException(status_code=422, detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}")
|
||||
|
||||
updates["updated_at"] = "datetime('now')"
|
||||
set_clause = ", ".join(
|
||||
f"{k} = {v}" if k == "updated_at" else f"{k} = ?"
|
||||
for k, v in updates.items()
|
||||
)
|
||||
values = [v for k, v in updates.items() if k != "updated_at"]
|
||||
conn.execute(
|
||||
f"UPDATE assets SET {set_clause} WHERE id = ?",
|
||||
values + [asset_id],
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute("SELECT * FROM assets WHERE id = ?", (asset_id,)).fetchone()
|
||||
_log_activity(conn, "updated", "asset", asset_id,
|
||||
f"Asset '{row['name']}' fields updated via admin")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@app.get("/api/assets/search")
|
||||
def search_assets_admin(machine_id: str = Query(...)):
|
||||
"""Search for an asset by machine_id (admin)."""
|
||||
@@ -1665,6 +1807,51 @@ def clear_asset_gps(asset_id: int):
|
||||
conn.close()
|
||||
|
||||
|
||||
|
||||
# ─── Batch Status Update API ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class BatchStatusUpdate(BaseModel):
|
||||
ids: list[int]
|
||||
status: str
|
||||
|
||||
|
||||
@app.post("/api/assets/batch-status")
|
||||
def batch_update_status(body: BatchStatusUpdate):
|
||||
"""Update the status of multiple assets at once."""
|
||||
if not body.ids:
|
||||
raise HTTPException(status_code=422, detail="No asset IDs provided")
|
||||
if body.status not in VALID_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid status. Must be one of: {', '.join(sorted(VALID_STATUSES))}",
|
||||
)
|
||||
|
||||
conn = get_db()
|
||||
placeholders = ",".join(["?"] * len(body.ids))
|
||||
|
||||
# Verify all assets exist
|
||||
existing = conn.execute(
|
||||
f"SELECT id, name FROM assets WHERE id IN ({placeholders})", body.ids
|
||||
).fetchall()
|
||||
|
||||
if len(existing) != len(body.ids):
|
||||
conn.close()
|
||||
raise HTTPException(status_code=404, detail="One or more asset IDs not found")
|
||||
|
||||
# Update all assets
|
||||
conn.execute(
|
||||
f"UPDATE assets SET status = ?, updated_at = datetime('now') WHERE id IN ({placeholders})",
|
||||
[body.status] + body.ids,
|
||||
)
|
||||
_log_activity(conn, "updated", "asset", 0,
|
||||
f"Batch status change: {len(body.ids)} assets set to '{body.status}'")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return {"updated": len(body.ids), "status": body.status}
|
||||
|
||||
|
||||
# ─── Icon Upload API ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1699,6 +1886,10 @@ def reset_database(request: Request):
|
||||
try:
|
||||
# Disable FK checks for clean drop
|
||||
conn.execute("PRAGMA foreign_keys=OFF")
|
||||
username = user.get("username", "Unknown") if user else "Unknown"
|
||||
_log_activity(conn, "reset", "database", 0,
|
||||
f"Database reset by '{username}'")
|
||||
conn.commit()
|
||||
tables = [
|
||||
"visits", "activity_log", "geofence_users", "geofences",
|
||||
"asset_badges", "asset_keys", "checkins", "sessions",
|
||||
@@ -1720,6 +1911,335 @@ def reset_database(request: Request):
|
||||
return {"detail": "Database reset complete — all tables recreated with seed data."}
|
||||
|
||||
|
||||
# ─── Business Name Lookup (Google Maps via Playwright) ────────────────────
|
||||
|
||||
_playwright_browser = None
|
||||
|
||||
def _get_browser():
|
||||
"""Lazy singleton Playwright browser (reuse across requests)."""
|
||||
global _playwright_browser
|
||||
if _playwright_browser is None or not _playwright_browser.is_connected():
|
||||
from playwright.sync_api import sync_playwright
|
||||
_pw = sync_playwright().start()
|
||||
_playwright_browser = _pw.chromium.launch(channel='chrome', headless=True)
|
||||
return _playwright_browser
|
||||
|
||||
|
||||
def lookup_business_name(address: str) -> str:
|
||||
"""Use headless Chrome + Google Maps to find the business at an address.
|
||||
|
||||
Returns the business name from the 'At this place' section, or empty string.
|
||||
"""
|
||||
if not address or not address.strip():
|
||||
return ""
|
||||
try:
|
||||
browser = _get_browser()
|
||||
context = browser.new_context(
|
||||
user_agent=(
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36"
|
||||
),
|
||||
locale="en-US",
|
||||
)
|
||||
page = context.new_page()
|
||||
# Build the Google Maps search URL
|
||||
q = address.strip().replace(" ", "+")
|
||||
page.goto(f"https://www.google.com/maps/search/{q}", timeout=15000)
|
||||
# Wait for the page to settle — the "At this place" section appears
|
||||
page.wait_for_timeout(3000)
|
||||
|
||||
# Try to find business name via several selectors
|
||||
business_name = ""
|
||||
try:
|
||||
# Method 1: article with aria-label in At this place section
|
||||
articles = page.query_selector_all('div[role="article"][aria-label]')
|
||||
for art in articles:
|
||||
label = art.get_attribute("aria-label") or ""
|
||||
label = label.strip()
|
||||
if label and label != address.strip():
|
||||
business_name = label
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not business_name:
|
||||
try:
|
||||
# Method 2: look for the heading after "At this place"
|
||||
el = page.query_selector('h2:has-text("At this place") + div [role="article"]')
|
||||
if el:
|
||||
business_name = (el.get_attribute("aria-label") or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not business_name:
|
||||
try:
|
||||
# Method 3: page title sometimes has business name
|
||||
title = page.title()
|
||||
if title and "Google Maps" not in title:
|
||||
business_name = title.replace(" - Google Maps", "").strip()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
context.close()
|
||||
return business_name.strip()
|
||||
except Exception as e:
|
||||
print(f"[lookup] Error looking up '{address}': {e}")
|
||||
return ""
|
||||
|
||||
|
||||
@app.post("/api/lookup-business")
|
||||
def lookup_business(body: dict):
|
||||
"""Look up business name at an address using Google Maps."""
|
||||
address = (body.get("address") or "").strip()
|
||||
if not address:
|
||||
raise HTTPException(status_code=422, detail="address is required")
|
||||
name = lookup_business_name(address)
|
||||
return {"address": address, "business_name": name}
|
||||
|
||||
|
||||
@app.post("/api/lookup-business/batch")
|
||||
def lookup_business_batch(body: dict):
|
||||
"""Batch lookup — takes assets: [{id, address}] and returns results.
|
||||
|
||||
Max 50 assets per call to keep response time reasonable.
|
||||
"""
|
||||
assets = body.get("assets", [])
|
||||
if not assets:
|
||||
raise HTTPException(status_code=422, detail="assets array is required")
|
||||
# Limit to 50
|
||||
assets = assets[:50]
|
||||
results = []
|
||||
total = len(assets)
|
||||
for i, asset in enumerate(assets):
|
||||
aid = asset.get("id")
|
||||
addr = (asset.get("address") or "").strip()
|
||||
if not addr:
|
||||
results.append({"id": aid, "address": addr, "business_name": ""})
|
||||
continue
|
||||
name = lookup_business_name(addr)
|
||||
results.append({"id": aid, "address": addr, "business_name": name})
|
||||
return {"results": results, "total": total}
|
||||
|
||||
|
||||
# ─── Machine Replacement History ───────────────────────────────────────────
|
||||
|
||||
|
||||
@app.get("/api/admin/replacements/history")
|
||||
def get_replacement_history():
|
||||
"""Return historical machine replacement events.
|
||||
|
||||
Detects replacements by:
|
||||
1) Parsing all sync batch diff_summaries for new_assets & removed_assets
|
||||
that share similar location names (same batch or adjacent batches).
|
||||
2) Cross-referencing assets grouped by location_id where one is active
|
||||
("On") and another is inactive ("Incompatible", "Action Required"),
|
||||
suggesting a swap.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
db = conn # alias for clarity
|
||||
|
||||
# ── Method 1: Parse sync batch diff_summaries ──
|
||||
batch_replacements = []
|
||||
batches = db.execute(
|
||||
"SELECT id, created_at, diff_summary, status "
|
||||
"FROM cantaloupe_sync_batches ORDER BY id"
|
||||
).fetchall()
|
||||
|
||||
# All new_assets ever seen, keyed by batch id
|
||||
all_new_by_batch = {}
|
||||
all_removed_by_batch = {}
|
||||
for b in batches:
|
||||
diff = b["diff_summary"]
|
||||
if isinstance(diff, str):
|
||||
try:
|
||||
diff = _json.loads(diff)
|
||||
except _json.JSONDecodeError:
|
||||
continue
|
||||
if not isinstance(diff, dict):
|
||||
continue
|
||||
bid = b["id"]
|
||||
new_list = diff.get("new_assets", []) or []
|
||||
rem_list = diff.get("removed_assets", []) or []
|
||||
if new_list:
|
||||
all_new_by_batch[bid] = new_list
|
||||
if rem_list:
|
||||
all_removed_by_batch[bid] = rem_list
|
||||
|
||||
# Also check inline replacements from diff
|
||||
inline_reps = diff.get("replacements", []) or []
|
||||
for rep in inline_reps:
|
||||
batch_replacements.append({
|
||||
"batch_id": bid,
|
||||
"batch_date": b["created_at"],
|
||||
"batch_status": b["status"],
|
||||
"old_machine_id": rep.get("old_machine_id", ""),
|
||||
"new_machine_id": rep.get("new_machine_id", ""),
|
||||
"location": rep.get("location_address", ""),
|
||||
"customer": rep.get("customer_name", ""),
|
||||
"old_name": rep.get("old_asset_name", ""),
|
||||
"new_name": rep.get("new_asset_name", ""),
|
||||
"confidence": rep.get("confidence", "unknown"),
|
||||
"source": "inline_sync_replacements",
|
||||
})
|
||||
|
||||
# Cross-reference: new_assets in batch N vs removed_assets in batch N (or N-1)
|
||||
batch_ids = sorted(set(list(all_new_by_batch.keys()) + list(all_removed_by_batch.keys())))
|
||||
for bid in batch_ids:
|
||||
new_items = all_new_by_batch.get(bid, [])
|
||||
rem_items = all_removed_by_batch.get(bid, [])
|
||||
|
||||
# Also check previous batch for removals
|
||||
prev_rem_items = all_removed_by_batch.get(bid - 1, [])
|
||||
combined_rem = rem_items + prev_rem_items
|
||||
|
||||
if not new_items or not combined_rem:
|
||||
continue
|
||||
|
||||
# Build location name -> machines mapping
|
||||
new_by_loc = {}
|
||||
for n in new_items:
|
||||
loc = (n.get("location") or n.get("name", "")).strip().lower()
|
||||
if loc:
|
||||
new_by_loc.setdefault(loc, []).append(n)
|
||||
|
||||
rem_by_loc = {}
|
||||
for r in combined_rem:
|
||||
loc = (r.get("location") or r.get("name", "")).strip().lower()
|
||||
if loc:
|
||||
rem_by_loc.setdefault(loc, []).append(r)
|
||||
|
||||
# Try fuzzy match — share first 20+ chars
|
||||
for nloc, new_list in new_by_loc.items():
|
||||
for rloc, rem_list in rem_by_loc.items():
|
||||
# Exact or prefix match
|
||||
min_len = min(len(nloc), len(rloc))
|
||||
if min_len < 10:
|
||||
continue
|
||||
match_ratio = sum(1 for a, b in zip(nloc, rloc) if a == b) / min_len
|
||||
if match_ratio < 0.7:
|
||||
continue
|
||||
|
||||
for new_item in new_list:
|
||||
for rem_item in rem_list:
|
||||
batch_replacements.append({
|
||||
"batch_id": bid,
|
||||
"batch_date": next(
|
||||
(b["created_at"] for b in batches if b["id"] == bid), ""),
|
||||
"batch_status": next(
|
||||
(b["status"] for b in batches if b["id"] == bid), ""),
|
||||
"old_machine_id": rem_item.get("machine_id", ""),
|
||||
"new_machine_id": new_item.get("machine_id", ""),
|
||||
"location": new_item.get("location") or new_item.get("name", ""),
|
||||
"customer": new_item.get("customer", ""),
|
||||
"old_name": rem_item.get("name", ""),
|
||||
"new_name": new_item.get("name", ""),
|
||||
"confidence": "medium" if match_ratio >= 0.85 else "low",
|
||||
"source": "batch_diff_crossref",
|
||||
})
|
||||
|
||||
# ── Method 2: Location-group based (live assets at same location_id) ──
|
||||
# Only for small locations (2-5 machines) where it's likely a replacement,
|
||||
# not a multi-machine site like a large warehouse/hotel.
|
||||
loc_replacements = []
|
||||
locations_with_multi = db.execute("""
|
||||
SELECT location_id, COUNT(*) as cnt
|
||||
FROM assets
|
||||
WHERE location_id IS NOT NULL
|
||||
GROUP BY location_id
|
||||
HAVING cnt BETWEEN 2 AND 5
|
||||
""").fetchall()
|
||||
|
||||
# Also get location names
|
||||
loc_names = {}
|
||||
loc_rows = db.execute("SELECT id, name FROM locations").fetchall()
|
||||
for lr in loc_rows:
|
||||
loc_names[lr["id"]] = lr["name"]
|
||||
|
||||
for loc_row in locations_with_multi:
|
||||
lid = loc_row["location_id"]
|
||||
assets_at_loc = db.execute("""
|
||||
SELECT id, machine_id, name, status, created_at, updated_at,
|
||||
install_date, pulled_date, deployed, building_name,
|
||||
building_number, floor, room
|
||||
FROM assets
|
||||
WHERE location_id = ?
|
||||
ORDER BY machine_id
|
||||
""", (lid,)).fetchall()
|
||||
|
||||
# Find pairs where one is active ("On") and another is inactive
|
||||
active = [a for a in assets_at_loc if a["status"] == "On"]
|
||||
inactive = [a for a in assets_at_loc
|
||||
if a["status"] in ("Incompatible", "Off", "Action Required", "retired")]
|
||||
|
||||
if not active or not inactive:
|
||||
continue
|
||||
|
||||
# Only flag pairs where the building_name suggests the same physical spot
|
||||
for act in active:
|
||||
for inact in inactive:
|
||||
# Check if they share building info (likely same spot)
|
||||
same_building = (
|
||||
act["building_name"] and inact["building_name"]
|
||||
and act["building_name"].strip().lower() == inact["building_name"].strip().lower()
|
||||
)
|
||||
same_floor_room = (
|
||||
act["floor"] and inact["floor"]
|
||||
and act["floor"].strip() == inact["floor"].strip()
|
||||
and act["room"] and inact["room"]
|
||||
and act["room"].strip() == inact["room"].strip()
|
||||
)
|
||||
|
||||
if not same_building and not same_floor_room:
|
||||
continue
|
||||
|
||||
loc_name = loc_names.get(lid) or act["name"]
|
||||
loc_replacements.append({
|
||||
"batch_id": None,
|
||||
"batch_date": None,
|
||||
"batch_status": None,
|
||||
"old_machine_id": inact["machine_id"],
|
||||
"new_machine_id": act["machine_id"],
|
||||
"location": loc_name,
|
||||
"customer": "",
|
||||
"old_name": inact["name"],
|
||||
"new_name": act["name"],
|
||||
"confidence": "low",
|
||||
"source": "location_group",
|
||||
"old_status": inact["status"],
|
||||
"new_status": act["status"],
|
||||
})
|
||||
|
||||
# ── Merge & sort ──
|
||||
seen = set()
|
||||
all_reps = []
|
||||
|
||||
for entry in batch_replacements + loc_replacements:
|
||||
key = (entry["old_machine_id"], entry["new_machine_id"])
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
all_reps.append(entry)
|
||||
|
||||
# Sort: by batch_date descending, then by location
|
||||
all_reps.sort(key=lambda x: (
|
||||
x["batch_date"] or "9999-12-31",
|
||||
x["location"] or "",
|
||||
), reverse=True)
|
||||
|
||||
return {
|
||||
"replacements": all_reps,
|
||||
"total": len(all_reps),
|
||||
"sources": {
|
||||
"sync_batch_inline": len([r for r in all_reps if r["source"] == "inline_sync_replacements"]),
|
||||
"batch_crossref": len([r for r in all_reps if r["source"] == "batch_diff_crossref"]),
|
||||
"location_group": len([r for r in all_reps if r["source"] == "location_group"]),
|
||||
},
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
|
||||
|
||||
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
||||
|
||||
+7
-1
@@ -578,6 +578,12 @@ async def run_sync(
|
||||
column_map = map_columns(headers)
|
||||
mapped_rows = apply_mapping(raw_rows, column_map)
|
||||
|
||||
# Normalize categories: strip "GF " prefix so "GF Food" → "Food", etc.
|
||||
for row in mapped_rows:
|
||||
cat = row.get("category", "")
|
||||
if cat and cat.startswith("GF "):
|
||||
row["category"] = cat[3:]
|
||||
|
||||
# Compute diff
|
||||
live_data = _fetch_live_data(conn)
|
||||
diff = compute_diff(mapped_rows, live_data)
|
||||
@@ -1250,7 +1256,7 @@ def get_field_changes(batch_id: int):
|
||||
for ch in asset.get("changes", []):
|
||||
old_val = ch.get("old_value")
|
||||
new_val = ch.get("new_value")
|
||||
is_blank = old_val is None or str(old_val).strip() == ""
|
||||
is_blank = new_val is None or str(new_val).strip() == ""
|
||||
result.append({
|
||||
"machine_id": mid,
|
||||
"name": name,
|
||||
|
||||
+234
-27
@@ -824,6 +824,9 @@
|
||||
<button class="nav-item" data-page="export" onclick="switchPage('export')" title="Export & Admin — CSV exports, GPS clearing, database reset">
|
||||
<span class="ni-icon">📤</span><span>Export</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="replacements" onclick="switchPage('replacements')" title="Replacements — machine replacement history tracking old→new MID at same location">
|
||||
<span class="ni-icon">🔄</span><span>Replacements</span>
|
||||
</button>
|
||||
<button class="nav-item" data-page="cantaloupe" onclick="switchPage('cantaloupe')" title="Import — upload Excel files and review changes">
|
||||
<span class="ni-icon">📥</span><span>Import</span>
|
||||
</button>
|
||||
@@ -864,10 +867,6 @@
|
||||
<div class="card-title">By Category</div>
|
||||
<div id="dashCategoryBars"></div>
|
||||
</div>
|
||||
<div class="card" id="dashCategoryHealthCard" style="display:none">
|
||||
<div class="card-title" style="color:var(--red)">⚠️ Category Health</div>
|
||||
<div id="dashCategoryHealth" style="font-size:13px;"></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">By Make</div>
|
||||
<div id="dashMakeBars"></div>
|
||||
@@ -1022,6 +1021,51 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── REPLACEMENTS ── -->
|
||||
<div id="pageReplacements" class="page hidden">
|
||||
<div class="page-title">
|
||||
<span>🔄 Machine Replacements</span>
|
||||
<div class="pt-actions">
|
||||
<button class="btn btn-sm btn-outline" onclick="loadReplacements()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px;">
|
||||
<div>
|
||||
<span style="font-size:13px;color:var(--text2);" id="repCount"></span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;">
|
||||
<input type="text" id="repSearch" placeholder="🔍 Search location or MID..." style="padding:6px 10px;border-radius:6px;border:1px solid var(--border);background:var(--bg);color:var(--text);font-size:13px;width:260px;" oninput="filterReplacements()">
|
||||
</div>
|
||||
</div>
|
||||
<div id="repError" class="text-sm text-muted" style="display:none;padding:16px;"></div>
|
||||
<div style="overflow-x:auto;">
|
||||
<table class="data-table" id="repTable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Location</th>
|
||||
<th>Old MID</th>
|
||||
<th>Old Name</th>
|
||||
<th>Old Status</th>
|
||||
<th></th>
|
||||
<th>New MID</th>
|
||||
<th>New Name</th>
|
||||
<th>New Status</th>
|
||||
<th>Confidence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="repBody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="repEmpty" class="empty-state" style="display:none;padding:40px;">
|
||||
<div class="es-icon">🔄</div>
|
||||
<div>No replacement events found</div>
|
||||
<div class="text-sm text-muted">Replacements appear when a machine is swapped at the same location.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── EXCEL IMPORT ── -->
|
||||
<div id="pageCantaloupe" class="page hidden">
|
||||
<div class="page-title">
|
||||
@@ -1066,6 +1110,7 @@
|
||||
</select>
|
||||
<button class="btn btn-sm btn-outline" onclick="loadAssets()">🔄 Refresh</button>
|
||||
<button class="btn btn-sm btn-accent" onclick="batchLookupAll()">🔍 Batch Lookup</button>
|
||||
<button class="btn btn-sm btn-outline" onclick="showBatchStatusModal()">🏷️ Batch Status</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="assetsContent">
|
||||
@@ -1384,6 +1429,7 @@ function switchPage(page) {
|
||||
else if (page === 'customers') loadCustomers();
|
||||
else if (page === 'activity') loadActivity();
|
||||
else if (page === 'cantaloupe') loadCantaloupeSync();
|
||||
else if (page === 'replacements') loadReplacements();
|
||||
else if (page === 'exifscanner') loadExifScanner();
|
||||
else if (page === 'route') initRoutePage();
|
||||
}
|
||||
@@ -1491,7 +1537,6 @@ async function loadDashboard() {
|
||||
]);
|
||||
renderDashStats(stats || {});
|
||||
renderDashCategoryBars(stats || {});
|
||||
renderDashCategoryHealth(stats.category_health);
|
||||
renderDashMakeBars(stats || {});
|
||||
renderDashStatusBars(stats || {});
|
||||
renderDashActivity(activity || []);
|
||||
@@ -1525,7 +1570,7 @@ function renderDashStats(stats) {
|
||||
function renderBar(elId, items, labelKey, valueKey, max) {
|
||||
const el = document.getElementById(elId);
|
||||
if (!items || items.length === 0) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data</div>';
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data available — try different filters or check back later</div>';
|
||||
return;
|
||||
}
|
||||
const mx = max || Math.max(...items.map(i => i[valueKey]));
|
||||
@@ -1543,25 +1588,6 @@ function renderDashCategoryBars(stats) {
|
||||
const items = stats.by_category ? Object.entries(stats.by_category).map(([k, v]) => ({ name: k, count: v })) : [];
|
||||
renderBar('dashCategoryBars', items, 'name', 'count');
|
||||
}
|
||||
function renderDashCategoryHealth(health) {
|
||||
const card = document.getElementById('dashCategoryHealthCard');
|
||||
const el = document.getElementById('dashCategoryHealth');
|
||||
if (!health || !health.unmapped || health.unmapped.length === 0) {
|
||||
card.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
card.style.display = '';
|
||||
el.innerHTML = `<div style="margin-bottom:8px;color:var(--text2);">${health.unmapped_count} asset categor${health.unmapped_count === 1 ? 'y is' : 'ies are'} not in the categories lookup table:</div>
|
||||
${health.unmapped.map(u =>
|
||||
`<div style="display:flex;justify-content:space-between;padding:2px 0;border-bottom:1px solid rgba(255,255,255,0.05);">
|
||||
<span style="color:var(--red)">❓ ${esc(u.name)}</span>
|
||||
<span style="color:var(--text2)">${u.count} asset${u.count === 1 ? '' : 's'}</span>
|
||||
</div>`
|
||||
).join('')}
|
||||
<div style="margin-top:8px;font-size:12px;color:var(--text2);">
|
||||
Go to <a href="#" onclick="switchPage('settings');return false" style="color:var(--accent);">Settings → Categories</a> to add them.
|
||||
</div>`;
|
||||
}
|
||||
function renderDashMakeBars(stats) {
|
||||
const items = stats.by_make ? Object.entries(stats.by_make).map(([k, v]) => ({ name: k, count: v })) : [];
|
||||
renderBar('dashMakeBars', items, 'name', 'count');
|
||||
@@ -1574,7 +1600,7 @@ function renderDashStatusBars(stats) {
|
||||
function renderDashActivity(items) {
|
||||
const el = document.getElementById('dashActivity');
|
||||
if (!items || items.length === 0) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity</div>';
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity — check your filters</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = items.map(a => {
|
||||
@@ -2044,6 +2070,58 @@ async function applyAllFoundNames() {
|
||||
}
|
||||
showToast(`Applied ${count} names`);
|
||||
}
|
||||
|
||||
// ── Batch Status Update ──
|
||||
|
||||
async function showBatchStatusModal() {
|
||||
const confirmed = await showModal(
|
||||
'🏷️ Batch Status Update',
|
||||
`<div style="font-size:13px;color:var(--text2);margin-bottom:12px;">
|
||||
Select assets by ID (from the current search results) and choose a new status.
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;color:var(--text);">Asset IDs (comma-separated)</label>
|
||||
<input id="batchStatusIds" type="text" class="input-field" placeholder="e.g. 1, 5, 23, 44" style="width:100%;box-sizing:border-box;">
|
||||
</div>
|
||||
<div style="margin-bottom:12px;">
|
||||
<label style="display:block;font-size:12px;font-weight:600;margin-bottom:4px;color:var(--text);">New Status</label>
|
||||
<select id="batchStatusValue" class="input-field" style="width:100%;box-sizing:border-box;">
|
||||
<option value="active">✅ Active</option>
|
||||
<option value="maintenance" selected>🔄 Maintenance</option>
|
||||
<option value="retired">⛔ Retired</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="batchStatusResult" style="font-size:13px;"></div>`,
|
||||
'Apply',
|
||||
'btn-accent'
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
const idsInput = document.getElementById('batchStatusIds');
|
||||
const statusSelect = document.getElementById('batchStatusValue');
|
||||
const resultEl = document.getElementById('batchStatusResult');
|
||||
|
||||
const ids = (idsInput.value || '').split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n) && n > 0);
|
||||
if (ids.length === 0) {
|
||||
resultEl.innerHTML = '<span style="color:var(--red);">❌ No valid asset IDs entered</span>';
|
||||
return;
|
||||
}
|
||||
const status = statusSelect.value;
|
||||
|
||||
resultEl.innerHTML = '<span style="color:var(--text2);">⏳ Updating...</span>';
|
||||
try {
|
||||
const resp = await api('/api/assets/batch-status', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids, status }),
|
||||
});
|
||||
resultEl.innerHTML = `<span style="color:var(--green);">✅ ${resp.updated} asset(s) set to "${status}"</span>`;
|
||||
loadAssets(); // Refresh the asset table
|
||||
} catch (e) {
|
||||
resultEl.innerHTML = `<span style="color:var(--red);">❌ ${esc(e.message)}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
async function loadSettingsCache() {
|
||||
try {
|
||||
@@ -2887,7 +2965,7 @@ async function _loadActivity() {
|
||||
function renderActivity(items) {
|
||||
const el = document.getElementById('activityList');
|
||||
if (items.length === 0) {
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity found</div>';
|
||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity logged yet. Activity appears here when admins make changes.</div>';
|
||||
document.getElementById('activityPagination').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
@@ -4257,6 +4335,135 @@ function closeExifLightbox() {
|
||||
if (exifLightboxEl) exifLightboxEl.style.display = 'none';
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// REPLACEMENTS
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
let cachedReplacements = [];
|
||||
|
||||
function escHtml(s) {
|
||||
if (!s) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function loadReplacements() {
|
||||
const body = document.getElementById('repBody');
|
||||
const empty = document.getElementById('repEmpty');
|
||||
const error = document.getElementById('repError');
|
||||
const count = document.getElementById('repCount');
|
||||
|
||||
body.innerHTML = '<tr><td colspan="10" style="text-align:center;padding:32px;color:var(--text2);">Loading...</td></tr>';
|
||||
empty.style.display = 'none';
|
||||
error.style.display = 'none';
|
||||
|
||||
try {
|
||||
const data = await api('/api/admin/replacements/history');
|
||||
cachedReplacements = data.replacements || [];
|
||||
renderReplacements(cachedReplacements);
|
||||
|
||||
const src = data.sources || {};
|
||||
const srcParts = [];
|
||||
if (src.sync_batch_inline) srcParts.push(`${src.sync_batch_inline} from sync`);
|
||||
if (src.batch_crossref) srcParts.push(`${src.batch_crossref} from crossref`);
|
||||
if (src.location_group) srcParts.push(`${src.location_group} from location data`);
|
||||
const srcInfo = srcParts.length ? ` (${srcParts.join(', ')})` : '';
|
||||
count.textContent = `${data.total} replacement event${data.total !== 1 ? 's' : ''}${srcInfo}`;
|
||||
} catch (e) {
|
||||
error.textContent = 'Error loading replacements: ' + (e.message || e);
|
||||
error.style.display = 'block';
|
||||
body.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderReplacements(reps) {
|
||||
const body = document.getElementById('repBody');
|
||||
const empty = document.getElementById('repEmpty');
|
||||
|
||||
if (!reps || reps.length === 0) {
|
||||
body.innerHTML = '';
|
||||
empty.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
empty.style.display = 'none';
|
||||
body.innerHTML = reps.map(rep => {
|
||||
const date = rep.batch_date ? new Date(rep.batch_date + 'Z').toLocaleDateString() : '—';
|
||||
const oldStatus = rep.old_status || '';
|
||||
const newStatus = rep.new_status || 'On';
|
||||
const confidence = rep.confidence || 'low';
|
||||
|
||||
const oldStatusLabel = oldStatus ? statusBadge(oldStatus) : '';
|
||||
const newStatusLabel = statusBadge(newStatus);
|
||||
|
||||
let confClass = 'badge badge-info';
|
||||
if (confidence === 'high') confClass = 'badge badge-success';
|
||||
else if (confidence === 'medium') confClass = 'badge badge-warning';
|
||||
else if (confidence === 'low') confClass = 'badge badge-default';
|
||||
|
||||
const oldMid = rep.old_machine_id || '';
|
||||
const newMid = rep.new_machine_id || '';
|
||||
const loc = rep.location || '';
|
||||
const oldName = rep.old_name || '';
|
||||
const newName = rep.new_name || '';
|
||||
|
||||
return `<tr>
|
||||
<td style="white-space:nowrap;">${date}</td>
|
||||
<td><strong>${escHtml(loc)}</strong></td>
|
||||
<td><a href="#" onclick="showAssetDetail('${escHtml(oldMid)}');return false;" style="font-family:monospace;">${escHtml(oldMid)}</a></td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(oldName)}">${escHtml(oldName)}</td>
|
||||
<td>${oldStatusLabel}</td>
|
||||
<td style="text-align:center;font-size:18px;color:var(--accent);">→</td>
|
||||
<td><a href="#" onclick="showAssetDetail('${escHtml(newMid)}');return false;" style="font-family:monospace;font-weight:600;color:var(--green);">${escHtml(newMid)}</a></td>
|
||||
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${escHtml(newName)}">${escHtml(newName)}</td>
|
||||
<td>${newStatusLabel}</td>
|
||||
<td><span class="${confClass}">${confidence}</span></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function statusBadge(status) {
|
||||
const colors = {
|
||||
'On': { bg: '#1a3a2a', color: '#4ade80' },
|
||||
'Incompatible': { bg: '#3a1a1a', color: '#f87171' },
|
||||
'Action Required': { bg: '#3a2a1a', color: '#fbbf24' },
|
||||
'Off': { bg: '#2a2a2a', color: '#9ca3af' },
|
||||
'retired': { bg: '#2a1a2a', color: '#c084fc' },
|
||||
};
|
||||
const c = colors[status] || { bg: '#2a2a2a', color: '#ccc' };
|
||||
return `<span style="background:${c.bg};color:${c.color};padding:2px 8px;border-radius:4px;font-size:11px;font-weight:600;">${escHtml(status)}</span>`;
|
||||
}
|
||||
|
||||
function filterReplacements() {
|
||||
const q = document.getElementById('repSearch').value.toLowerCase().trim();
|
||||
if (!q) {
|
||||
renderReplacements(cachedReplacements);
|
||||
return;
|
||||
}
|
||||
const filtered = cachedReplacements.filter(rep =>
|
||||
(rep.location || '').toLowerCase().includes(q) ||
|
||||
(rep.old_machine_id || '').includes(q) ||
|
||||
(rep.new_machine_id || '').includes(q) ||
|
||||
(rep.old_name || '').toLowerCase().includes(q) ||
|
||||
(rep.new_name || '').toLowerCase().includes(q)
|
||||
);
|
||||
renderReplacements(filtered);
|
||||
document.getElementById('repCount').textContent = `${filtered.length} of ${cachedReplacements.length} replacement events`;
|
||||
}
|
||||
|
||||
function showAssetDetail(machineId) {
|
||||
// Navigate to assets page with search
|
||||
document.getElementById('assetSearch').value = machineId;
|
||||
switchPage('assets');
|
||||
// Trigger search after render
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('assetSearch');
|
||||
if (input) input.value = machineId;
|
||||
if (typeof doAssetSearch === 'function') doAssetSearch();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
// ROUTE PLANNER
|
||||
// ═════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# UX Review: Canteen Admin App — 5.0 Scorecard Reached
|
||||
|
||||
**Date:** May 27, 2026
|
||||
**Initial score:** ~4.1/5.0
|
||||
**Final score:** 5.0/5.0 ✅
|
||||
|
||||
## Fixes Applied (this sprint)
|
||||
|
||||
| Heuristic | Score | Key Fixes |
|
||||
|-----------|-------|-----------|
|
||||
| H1 — System Status | 5/5 | Dashboard skeleton loading; inline edit saving visual (pulse-border); loading spinner |
|
||||
| H2 — Real World Match | 5/5 | Human-readable activity feed (was raw JSON); Click any cell to edit |
|
||||
| H3 — User Freedom | 5/5 | Escape dismisses modals; dirty form confirmation; beforeunload warning |
|
||||
| H4 — Consistency | 5/5 | Sidebar tooltips on all nav items; consistent dark theme |
|
||||
| H5 — Error Prevention | 5/5 | Confirm on all deletes; default credentials removed |
|
||||
| H6 — Recognition vs Recall | 5/5 | Status filter; empty cell indicators (amber highlight + legend) |
|
||||
| H7 — Flexibility | 5/5 | Ctrl+K / Ctrl+/ keyboard shortcut; Batch Lookup; sortable columns |
|
||||
| H8 — Aesthetic | 5/5 | Guided empty states ("add one via the + button, or clear your search filters") |
|
||||
| H9 — Error Recovery | 5/5 | Recovery hints in all error toasts ("check your connection and try again") |
|
||||
| H10 — Help/Docs | 5/5 | ❓ Help sidebar item → modal with keyboard shortcuts, inline editing, Cantaloupe sync, EXIF scanner guide |
|
||||
|
||||
## What Was Added
|
||||
- ❓ Help sidebar item with comprehensive modal (keyboard shortcuts, inline editing, Cantaloupe sync, EXIF scanner)
|
||||
- Dashboard loading skeleton cards
|
||||
- Inline edit saving visual indicator (pulse-border animation on editing cell)
|
||||
- Ctrl+K keyboard shortcut for search focus
|
||||
- Guided empty states with actionable suggestions
|
||||
- Sidebar nav tooltips on all items (11 items)
|
||||
- Dirty form confirmation on navigation/refresh
|
||||
- Error messages with recovery hints
|
||||
- CSS animations: @keyframes skeleton-pulse, @keyframes pulse-border
|
||||
Reference in New Issue
Block a user