Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4988fdaa21 | |||
| ae6685a401 | |||
| fb2db1e76a | |||
| d58834bc56 | |||
| 750cde68c9 |
+5
-3
@@ -1,7 +1,9 @@
|
|||||||
.venv/
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
assets.db
|
||||||
|
uploads/
|
||||||
|
.venv/
|
||||||
*.db
|
*.db
|
||||||
*.db.real
|
|
||||||
*.xlsx
|
*.xlsx
|
||||||
.env
|
admin.db
|
||||||
|
canteen_admin.db
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# 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.
|
|
||||||
+29
-549
@@ -310,8 +310,11 @@ def _seed_if_empty(conn: sqlite3.Connection, table: str, columns: tuple, rows: l
|
|||||||
def _seed_data(conn: sqlite3.Connection):
|
def _seed_data(conn: sqlite3.Connection):
|
||||||
"""Insert default seed data for lookup tables."""
|
"""Insert default seed data for lookup tables."""
|
||||||
_seed_if_empty(conn, "categories", ("name", "icon"), [
|
_seed_if_empty(conn, "categories", ("name", "icon"), [
|
||||||
("Furniture", "🪑"), ("Appliances", "🔌"),
|
("Bev", "🥤"), ("Snack", "🍿"),
|
||||||
("Utensils & Serveware", "🍽️"), ("Equipment", "⚙️"), ("Other", "📦"),
|
("Food", "🍔"), ("Equipment", "🔧"),
|
||||||
|
("Appliances", "🔌"), ("Markets", "🏪"),
|
||||||
|
("Coffee", "☕"),
|
||||||
|
("Other", "📦"),
|
||||||
])
|
])
|
||||||
_seed_if_empty(conn, "key_names", ("name",), [
|
_seed_if_empty(conn, "key_names", ("name",), [
|
||||||
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
|
("MK500",), ("Green Dot",), ("Red Key",), ("Blue Key",),
|
||||||
@@ -406,7 +409,7 @@ async def auth_middleware(request: Request, call_next):
|
|||||||
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
if os.environ.get("CANTEEN_SKIP_AUTH") == "1":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
if not path.startswith("/api/") or path == "/api/auth/login" or path.startswith("/api/lookup-business"):
|
if not path.startswith("/api/") or path == "/api/auth/login":
|
||||||
return await call_next(request)
|
return await call_next(request)
|
||||||
|
|
||||||
auth_header = request.headers.get("Authorization", "")
|
auth_header = request.headers.get("Authorization", "")
|
||||||
@@ -756,9 +759,6 @@ def update_user(user_id: int, body: UserUpdate):
|
|||||||
if body.role is not None:
|
if body.role is not None:
|
||||||
role = validate_roles(body.role)
|
role = validate_roles(body.role)
|
||||||
conn.execute("UPDATE users SET role = ? WHERE id = ?", (role, user_id))
|
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:
|
if body.password is not None:
|
||||||
pw = body.password.strip()
|
pw = body.password.strip()
|
||||||
@@ -767,9 +767,6 @@ def update_user(user_id: int, body: UserUpdate):
|
|||||||
raise HTTPException(status_code=422, detail="Password must not be empty")
|
raise HTTPException(status_code=422, detail="Password must not be empty")
|
||||||
password_hash = _hash_password(pw)
|
password_hash = _hash_password(pw)
|
||||||
conn.execute("UPDATE users SET password_hash = ? WHERE id = ?", (password_hash, user_id))
|
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()
|
conn.commit()
|
||||||
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
row = conn.execute("SELECT * FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
@@ -780,15 +777,12 @@ def update_user(user_id: int, body: UserUpdate):
|
|||||||
@app.delete("/api/users/{user_id}", status_code=204)
|
@app.delete("/api/users/{user_id}", status_code=204)
|
||||||
def delete_user(user_id: int):
|
def delete_user(user_id: int):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute("SELECT id, username FROM users WHERE id = ?", (user_id,)).fetchone()
|
existing = conn.execute("SELECT id FROM users WHERE id = ?", (user_id,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="User not found")
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
conn.execute("DELETE FROM users WHERE id = ?", (user_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_log_activity(conn, "deleted", "user", user_id,
|
|
||||||
f"User '{existing['username']}' deleted")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -838,9 +832,6 @@ def login(body: dict):
|
|||||||
(row["id"], token),
|
(row["id"], token),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_log_activity(conn, "login", "user", row["id"],
|
|
||||||
f"User '{username}' logged in")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
result = _user_to_dict(row)
|
result = _user_to_dict(row)
|
||||||
@@ -1004,9 +995,6 @@ def create_location(body: LocationCreate):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
loc_id = cursor.lastrowid
|
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()
|
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||||
result = row_to_dict(row)
|
result = row_to_dict(row)
|
||||||
result["rooms"] = []
|
result["rooms"] = []
|
||||||
@@ -1056,7 +1044,7 @@ _LOCATION_FIELDS = [
|
|||||||
@app.put("/api/locations/{loc_id}")
|
@app.put("/api/locations/{loc_id}")
|
||||||
def update_location(loc_id: int, body: LocationUpdate):
|
def update_location(loc_id: int, body: LocationUpdate):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Location not found")
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
@@ -1092,9 +1080,6 @@ def update_location(loc_id: int, body: LocationUpdate):
|
|||||||
values + [loc_id],
|
values + [loc_id],
|
||||||
)
|
)
|
||||||
conn.commit()
|
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()
|
row = conn.execute("SELECT * FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||||
result = row_to_dict(row)
|
result = row_to_dict(row)
|
||||||
@@ -1106,15 +1091,12 @@ def update_location(loc_id: int, body: LocationUpdate):
|
|||||||
@app.delete("/api/locations/{loc_id}", status_code=204)
|
@app.delete("/api/locations/{loc_id}", status_code=204)
|
||||||
def delete_location(loc_id: int):
|
def delete_location(loc_id: int):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute("SELECT id, name FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
existing = conn.execute("SELECT id FROM locations WHERE id = ?", (loc_id,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Location not found")
|
raise HTTPException(status_code=404, detail="Location not found")
|
||||||
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
conn.execute("DELETE FROM locations WHERE id = ?", (loc_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_log_activity(conn, "deleted", "location", loc_id,
|
|
||||||
f"Location '{existing['name']}' deleted")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -1156,9 +1138,6 @@ def create_room(body: RoomCreate):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
room_id = cursor.lastrowid
|
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()
|
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
return row_to_dict(row)
|
return row_to_dict(row)
|
||||||
@@ -1167,7 +1146,7 @@ def create_room(body: RoomCreate):
|
|||||||
@app.put("/api/rooms/{room_id}")
|
@app.put("/api/rooms/{room_id}")
|
||||||
def update_room(room_id: int, body: RoomUpdate):
|
def update_room(room_id: int, body: RoomUpdate):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Room not found")
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
@@ -1193,9 +1172,6 @@ def update_room(room_id: int, body: RoomUpdate):
|
|||||||
values + [room_id],
|
values + [room_id],
|
||||||
)
|
)
|
||||||
conn.commit()
|
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()
|
row = conn.execute("SELECT * FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1205,15 +1181,12 @@ def update_room(room_id: int, body: RoomUpdate):
|
|||||||
@app.delete("/api/rooms/{room_id}", status_code=204)
|
@app.delete("/api/rooms/{room_id}", status_code=204)
|
||||||
def delete_room(room_id: int):
|
def delete_room(room_id: int):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute("SELECT id, name FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
existing = conn.execute("SELECT id FROM rooms WHERE id = ?", (room_id,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Room not found")
|
raise HTTPException(status_code=404, detail="Room not found")
|
||||||
conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,))
|
conn.execute("DELETE FROM rooms WHERE id = ?", (room_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_log_activity(conn, "deleted", "room", room_id,
|
|
||||||
f"Room '{existing['name']}' deleted")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -1256,9 +1229,6 @@ def _settings_create(conn: sqlite3.Connection, entity: str, body: dict):
|
|||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
eid = cursor.lastrowid
|
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()
|
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||||
return row_to_dict(row)
|
return row_to_dict(row)
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
@@ -1270,7 +1240,7 @@ def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict
|
|||||||
table = schema["table"]
|
table = schema["table"]
|
||||||
columns = schema["columns"]
|
columns = schema["columns"]
|
||||||
|
|
||||||
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
|
existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
||||||
|
|
||||||
@@ -1286,9 +1256,6 @@ def _settings_update(conn: sqlite3.Connection, entity: str, eid: int, body: dict
|
|||||||
f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid]
|
f"UPDATE {table} SET {set_clause} WHERE id = ?", values + [eid]
|
||||||
)
|
)
|
||||||
conn.commit()
|
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()
|
row = conn.execute(f"SELECT * FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||||
return row_to_dict(row)
|
return row_to_dict(row)
|
||||||
@@ -1306,14 +1273,11 @@ def _settings_get(conn: sqlite3.Connection, entity: str, eid: int):
|
|||||||
def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int):
|
def _settings_delete(conn: sqlite3.Connection, entity: str, eid: int):
|
||||||
schema = _SETTINGS_SCHEMAS[entity]
|
schema = _SETTINGS_SCHEMAS[entity]
|
||||||
table = schema["table"]
|
table = schema["table"]
|
||||||
existing = conn.execute(f"SELECT id, name FROM {table} WHERE id = ?", (eid,)).fetchone()
|
existing = conn.execute(f"SELECT id FROM {table} WHERE id = ?", (eid,)).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
raise HTTPException(status_code=404, detail=f"{entity[:-1]} not found")
|
||||||
conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,))
|
conn.execute(f"DELETE FROM {table} WHERE id = ?", (eid,))
|
||||||
conn.commit()
|
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"]
|
_SETTINGS_ENTITIES = ["categories", "makes", "models", "key_names", "key_types", "badge_types"]
|
||||||
@@ -1415,7 +1379,7 @@ def list_geofences():
|
|||||||
def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
|
"SELECT id FROM geofences WHERE id = ?", (geofence_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
@@ -1446,10 +1410,6 @@ def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
|||||||
_sync_geofence_users(conn, geofence_id, body.user_ids)
|
_sync_geofence_users(conn, geofence_id, body.user_ids)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
_log_activity(conn, "updated", "geofence", geofence_id,
|
|
||||||
f"Geofence '{existing['name']}' updated")
|
|
||||||
conn.commit()
|
|
||||||
|
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT * FROM geofences WHERE id = ?", (geofence_id,)
|
"SELECT * FROM geofences WHERE id = ?", (geofence_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -1462,16 +1422,13 @@ def update_geofence(geofence_id: int, body: GeofenceUpdate):
|
|||||||
def delete_geofence(geofence_id: int):
|
def delete_geofence(geofence_id: int):
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
existing = conn.execute(
|
existing = conn.execute(
|
||||||
"SELECT id, name FROM geofences WHERE id = ?", (geofence_id,)
|
"SELECT id FROM geofences WHERE id = ?", (geofence_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
if existing is None:
|
if existing is None:
|
||||||
conn.close()
|
conn.close()
|
||||||
raise HTTPException(status_code=404, detail="Geofence not found")
|
raise HTTPException(status_code=404, detail="Geofence not found")
|
||||||
conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,))
|
conn.execute("DELETE FROM geofences WHERE id = ?", (geofence_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
_log_activity(conn, "deleted", "geofence", geofence_id,
|
|
||||||
f"Geofence '{existing['name']}' deleted")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
@@ -1572,6 +1529,19 @@ def get_stats():
|
|||||||
).fetchall()
|
).fetchall()
|
||||||
by_make = {r["make"]: r["cnt"] for r in makes}
|
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()
|
conn.close()
|
||||||
return {
|
return {
|
||||||
"total_assets": total_assets,
|
"total_assets": total_assets,
|
||||||
@@ -1584,6 +1554,7 @@ def get_stats():
|
|||||||
"top_visited": top_visited_list,
|
"top_visited": top_visited_list,
|
||||||
"time_on_site": time_on_site,
|
"time_on_site": time_on_site,
|
||||||
"by_make": by_make,
|
"by_make": by_make,
|
||||||
|
"category_health": category_health,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -1653,119 +1624,6 @@ def export_checkins_csv(asset_id: Optional[int] = Query(None)):
|
|||||||
# ─── Asset GPS Management ──────────────────────────────────────────────────
|
# ─── 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")
|
@app.get("/api/assets/search")
|
||||||
def search_assets_admin(machine_id: str = Query(...)):
|
def search_assets_admin(machine_id: str = Query(...)):
|
||||||
"""Search for an asset by machine_id (admin)."""
|
"""Search for an asset by machine_id (admin)."""
|
||||||
@@ -1807,51 +1665,6 @@ def clear_asset_gps(asset_id: int):
|
|||||||
conn.close()
|
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 ────────────────────────────────────────────────────────
|
# ─── Icon Upload API ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
@@ -1886,10 +1699,6 @@ def reset_database(request: Request):
|
|||||||
try:
|
try:
|
||||||
# Disable FK checks for clean drop
|
# Disable FK checks for clean drop
|
||||||
conn.execute("PRAGMA foreign_keys=OFF")
|
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 = [
|
tables = [
|
||||||
"visits", "activity_log", "geofence_users", "geofences",
|
"visits", "activity_log", "geofence_users", "geofences",
|
||||||
"asset_badges", "asset_keys", "checkins", "sessions",
|
"asset_badges", "asset_keys", "checkins", "sessions",
|
||||||
@@ -1911,335 +1720,6 @@ def reset_database(request: Request):
|
|||||||
return {"detail": "Database reset complete — all tables recreated with seed data."}
|
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) ────────────────────────
|
# ─── SPA Frontend (catch-all — MUST be last route) ────────────────────────
|
||||||
|
|
||||||
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
FRONTEND_INDEX = Path(__file__).parent / "static" / "index.html"
|
||||||
|
|||||||
+1
-7
@@ -578,12 +578,6 @@ async def run_sync(
|
|||||||
column_map = map_columns(headers)
|
column_map = map_columns(headers)
|
||||||
mapped_rows = apply_mapping(raw_rows, column_map)
|
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
|
# Compute diff
|
||||||
live_data = _fetch_live_data(conn)
|
live_data = _fetch_live_data(conn)
|
||||||
diff = compute_diff(mapped_rows, live_data)
|
diff = compute_diff(mapped_rows, live_data)
|
||||||
@@ -1256,7 +1250,7 @@ def get_field_changes(batch_id: int):
|
|||||||
for ch in asset.get("changes", []):
|
for ch in asset.get("changes", []):
|
||||||
old_val = ch.get("old_value")
|
old_val = ch.get("old_value")
|
||||||
new_val = ch.get("new_value")
|
new_val = ch.get("new_value")
|
||||||
is_blank = new_val is None or str(new_val).strip() == ""
|
is_blank = old_val is None or str(old_val).strip() == ""
|
||||||
result.append({
|
result.append({
|
||||||
"machine_id": mid,
|
"machine_id": mid,
|
||||||
"name": name,
|
"name": name,
|
||||||
|
|||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# 🚀 Dev instance — admin server on port 8092, points to dev DB
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
PORT=8092
|
||||||
|
MAIN_DEV_DIR="$HOME/projects/canteen-asset-tracker-dev"
|
||||||
|
CANTEEN_DB_PATH="${MAIN_DEV_DIR}/assets.dev.db"
|
||||||
|
|
||||||
|
echo "🚀 Starting DEV admin server on http://0.0.0.0:${PORT}"
|
||||||
|
echo " DB: ${CANTEEN_DB_PATH}"
|
||||||
|
exec python -m uvicorn admin_server:app \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--port "$PORT" \
|
||||||
|
--log-level info
|
||||||
+27
-234
@@ -824,9 +824,6 @@
|
|||||||
<button class="nav-item" data-page="export" onclick="switchPage('export')" title="Export & Admin — CSV exports, GPS clearing, database reset">
|
<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>
|
<span class="ni-icon">📤</span><span>Export</span>
|
||||||
</button>
|
</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">
|
<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>
|
<span class="ni-icon">📥</span><span>Import</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -867,6 +864,10 @@
|
|||||||
<div class="card-title">By Category</div>
|
<div class="card-title">By Category</div>
|
||||||
<div id="dashCategoryBars"></div>
|
<div id="dashCategoryBars"></div>
|
||||||
</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">
|
||||||
<div class="card-title">By Make</div>
|
<div class="card-title">By Make</div>
|
||||||
<div id="dashMakeBars"></div>
|
<div id="dashMakeBars"></div>
|
||||||
@@ -1021,51 +1022,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ── -->
|
<!-- ── EXCEL IMPORT ── -->
|
||||||
<div id="pageCantaloupe" class="page hidden">
|
<div id="pageCantaloupe" class="page hidden">
|
||||||
<div class="page-title">
|
<div class="page-title">
|
||||||
@@ -1110,7 +1066,6 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn btn-sm btn-outline" onclick="loadAssets()">🔄 Refresh</button>
|
<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-accent" onclick="batchLookupAll()">🔍 Batch Lookup</button>
|
||||||
<button class="btn btn-sm btn-outline" onclick="showBatchStatusModal()">🏷️ Batch Status</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="assetsContent">
|
<div id="assetsContent">
|
||||||
@@ -1429,7 +1384,6 @@ function switchPage(page) {
|
|||||||
else if (page === 'customers') loadCustomers();
|
else if (page === 'customers') loadCustomers();
|
||||||
else if (page === 'activity') loadActivity();
|
else if (page === 'activity') loadActivity();
|
||||||
else if (page === 'cantaloupe') loadCantaloupeSync();
|
else if (page === 'cantaloupe') loadCantaloupeSync();
|
||||||
else if (page === 'replacements') loadReplacements();
|
|
||||||
else if (page === 'exifscanner') loadExifScanner();
|
else if (page === 'exifscanner') loadExifScanner();
|
||||||
else if (page === 'route') initRoutePage();
|
else if (page === 'route') initRoutePage();
|
||||||
}
|
}
|
||||||
@@ -1537,6 +1491,7 @@ async function loadDashboard() {
|
|||||||
]);
|
]);
|
||||||
renderDashStats(stats || {});
|
renderDashStats(stats || {});
|
||||||
renderDashCategoryBars(stats || {});
|
renderDashCategoryBars(stats || {});
|
||||||
|
renderDashCategoryHealth(stats.category_health);
|
||||||
renderDashMakeBars(stats || {});
|
renderDashMakeBars(stats || {});
|
||||||
renderDashStatusBars(stats || {});
|
renderDashStatusBars(stats || {});
|
||||||
renderDashActivity(activity || []);
|
renderDashActivity(activity || []);
|
||||||
@@ -1570,7 +1525,7 @@ function renderDashStats(stats) {
|
|||||||
function renderBar(elId, items, labelKey, valueKey, max) {
|
function renderBar(elId, items, labelKey, valueKey, max) {
|
||||||
const el = document.getElementById(elId);
|
const el = document.getElementById(elId);
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data available — try different filters or check back later</div>';
|
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No data</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const mx = max || Math.max(...items.map(i => i[valueKey]));
|
const mx = max || Math.max(...items.map(i => i[valueKey]));
|
||||||
@@ -1588,6 +1543,25 @@ function renderDashCategoryBars(stats) {
|
|||||||
const items = stats.by_category ? Object.entries(stats.by_category).map(([k, v]) => ({ name: k, count: v })) : [];
|
const items = stats.by_category ? Object.entries(stats.by_category).map(([k, v]) => ({ name: k, count: v })) : [];
|
||||||
renderBar('dashCategoryBars', items, 'name', 'count');
|
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) {
|
function renderDashMakeBars(stats) {
|
||||||
const items = stats.by_make ? Object.entries(stats.by_make).map(([k, v]) => ({ name: k, count: v })) : [];
|
const items = stats.by_make ? Object.entries(stats.by_make).map(([k, v]) => ({ name: k, count: v })) : [];
|
||||||
renderBar('dashMakeBars', items, 'name', 'count');
|
renderBar('dashMakeBars', items, 'name', 'count');
|
||||||
@@ -1600,7 +1574,7 @@ function renderDashStatusBars(stats) {
|
|||||||
function renderDashActivity(items) {
|
function renderDashActivity(items) {
|
||||||
const el = document.getElementById('dashActivity');
|
const el = document.getElementById('dashActivity');
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity — check your filters</div>';
|
el.innerHTML = '<div class="empty-state"><div class="es-icon">📭</div>No recent activity</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = items.map(a => {
|
el.innerHTML = items.map(a => {
|
||||||
@@ -2070,58 +2044,6 @@ async function applyAllFoundNames() {
|
|||||||
}
|
}
|
||||||
showToast(`Applied ${count} names`);
|
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() {
|
async function loadSettingsCache() {
|
||||||
try {
|
try {
|
||||||
@@ -2965,7 +2887,7 @@ async function _loadActivity() {
|
|||||||
function renderActivity(items) {
|
function renderActivity(items) {
|
||||||
const el = document.getElementById('activityList');
|
const el = document.getElementById('activityList');
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity logged yet. Activity appears here when admins make changes.</div>';
|
el.innerHTML = '<div class="empty-state"><div class="es-icon">📋</div>No activity found</div>';
|
||||||
document.getElementById('activityPagination').innerHTML = '';
|
document.getElementById('activityPagination').innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4335,135 +4257,6 @@ function closeExifLightbox() {
|
|||||||
if (exifLightboxEl) exifLightboxEl.style.display = 'none';
|
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
|
// ROUTE PLANNER
|
||||||
// ═════════════════════════════════════════════════════════════════════════════
|
// ═════════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
# 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