From 29bbaead86efc63a9b1f662a5aaa963db582e720 Mon Sep 17 00:00:00 2001 From: bonamin Date: Thu, 19 Mar 2026 11:11:29 +0200 Subject: [PATCH] update: added assets manager and extra nvs settings on cloudflash --- backend/manufacturing/router.py | 159 +++- backend/manufacturing/service.py | 89 +- .../bell_systems_horizontal_darkMode.png | Bin 0 -> 16838 bytes backend/utils/emails/device_assigned_mail.py | 220 +++++ backend/utils/emails/device_mfged_mail.py | 155 ++++ frontend/package-lock.json | 268 +++++- frontend/package.json | 1 + frontend/src/cloudflash/CloudFlashPage.jsx | 211 +++-- frontend/src/crm/customers/CustomerDetail.jsx | 2 +- frontend/src/devices/DeviceDetail.jsx | 53 +- frontend/src/equipment/NotesPanel.jsx | 4 +- frontend/src/firmware/FirmwareManager.jsx | 213 +---- frontend/src/index.css | 26 + .../src/manufacturing/DeviceInventory.jsx | 12 +- .../manufacturing/DeviceInventoryDetail.jsx | 608 ++++++++++---- .../src/manufacturing/FlashAssetManager.jsx | 793 ++++++++++++++++++ nginx/nginx.conf | 1 + 17 files changed, 2369 insertions(+), 446 deletions(-) create mode 100644 backend/utils/emails/assets/bell_systems_horizontal_darkMode.png create mode 100644 backend/utils/emails/device_assigned_mail.py create mode 100644 backend/utils/emails/device_mfged_mail.py create mode 100644 frontend/src/manufacturing/FlashAssetManager.jsx diff --git a/backend/manufacturing/router.py b/backend/manufacturing/router.py index caa69ae..d37bc70 100644 --- a/backend/manufacturing/router.py +++ b/backend/manufacturing/router.py @@ -201,13 +201,18 @@ async def patch_lifecycle_entry( return _doc_to_inventory_item(doc_ref.get()) -@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201) +@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=200) async def create_lifecycle_entry( sn: str, body: LifecycleEntryCreate, user: TokenPayload = Depends(require_permission("manufacturing", "edit")), ): - """Create a lifecycle history entry for a step that has no entry yet (on-the-fly).""" + """Upsert a lifecycle history entry for the given status_id. + + If an entry for this status already exists it is overwritten in-place; + otherwise a new entry is appended. This prevents duplicate entries when + a status is visited more than once (max one entry per status). + """ from datetime import datetime, timezone db = get_firestore() docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) @@ -215,14 +220,25 @@ async def create_lifecycle_entry( raise HTTPException(status_code=404, detail="Device not found") doc_ref = docs[0].reference data = docs[0].to_dict() or {} - history = data.get("lifecycle_history") or [] + history = list(data.get("lifecycle_history") or []) + new_entry = { "status_id": body.status_id, "date": body.date or datetime.now(timezone.utc).isoformat(), "note": body.note, "set_by": user.email, } - history.append(new_entry) + + # Overwrite existing entry for this status if present, else append + existing_idx = next( + (i for i, e in enumerate(history) if e.get("status_id") == body.status_id), + None, + ) + if existing_idx is not None: + history[existing_idx] = new_entry + else: + history.append(new_entry) + doc_ref.update({"lifecycle_history": history}) from manufacturing.service import _doc_to_inventory_item return _doc_to_inventory_item(doc_ref.get()) @@ -313,6 +329,91 @@ async def delete_device( ) +@router.post("/devices/{sn}/email/manufactured", status_code=204) +async def send_manufactured_email( + sn: str, + user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + """Send the 'device manufactured' notification to the assigned customer's email.""" + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise HTTPException(status_code=404, detail="Device not found") + data = docs[0].to_dict() or {} + customer_id = data.get("customer_id") + if not customer_id: + raise HTTPException(status_code=400, detail="No customer assigned to this device") + customer_doc = db.collection("crm_customers").document(customer_id).get() + if not customer_doc.exists: + raise HTTPException(status_code=404, detail="Assigned customer not found") + cdata = customer_doc.to_dict() or {} + email = cdata.get("email") + if not email: + raise HTTPException(status_code=400, detail="Customer has no email address") + name_parts = [cdata.get("name") or "", cdata.get("surname") or ""] + customer_name = " ".join(p for p in name_parts if p).strip() or None + hw_family = data.get("hw_family") or data.get("hw_type") or "" + from utils.emails.device_mfged_mail import send_device_manufactured_email + send_device_manufactured_email( + customer_email=email, + serial_number=sn, + device_name=hw_family.replace("_", " ").title(), + customer_name=customer_name, + ) + await audit.log_action( + admin_user=user.email, + action="email_manufactured_sent", + serial_number=sn, + detail={"recipient": email}, + ) + + +@router.post("/devices/{sn}/email/assigned", status_code=204) +async def send_assigned_email( + sn: str, + user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + """Send the 'device assigned / app instructions' email to the assigned user(s).""" + db = get_firestore() + docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) + if not docs: + raise HTTPException(status_code=404, detail="Device not found") + data = docs[0].to_dict() or {} + user_list = data.get("user_list") or [] + if not user_list: + raise HTTPException(status_code=400, detail="No users assigned to this device") + hw_family = data.get("hw_family") or data.get("hw_type") or "" + device_name = hw_family.replace("_", " ").title() + from utils.emails.device_assigned_mail import send_device_assigned_email + errors = [] + for uid in user_list: + try: + user_doc = db.collection("users").document(uid).get() + if not user_doc.exists: + continue + udata = user_doc.to_dict() or {} + email = udata.get("email") + if not email: + continue + display_name = udata.get("display_name") or udata.get("name") or None + send_device_assigned_email( + user_email=email, + serial_number=sn, + device_name=device_name, + user_name=display_name, + ) + except Exception as exc: + errors.append(str(exc)) + if errors: + raise HTTPException(status_code=500, detail=f"Some emails failed: {'; '.join(errors)}") + await audit.log_action( + admin_user=user.email, + action="email_assigned_sent", + serial_number=sn, + detail={"user_count": len(user_list)}, + ) + + @router.delete("/devices", status_code=200) async def delete_unprovisioned( user: TokenPayload = Depends(require_permission("manufacturing", "delete")), @@ -347,6 +448,56 @@ def redirect_firmware( # Upload once per hw_type after each PlatformIO build that changes the layout. # ───────────────────────────────────────────────────────────────────────────── +@router.get("/flash-assets") +def list_flash_assets( + _user: TokenPayload = Depends(require_permission("manufacturing", "view")), +): + """Return asset status for all known board types (and any discovered bespoke UIDs). + + Checks the filesystem directly — no database involved. + Each entry contains: hw_type, bootloader (exists, size, uploaded_at), partitions (same), note. + """ + return {"assets": service.list_flash_assets()} + + +@router.delete("/flash-assets/{hw_type}/{asset}", status_code=204) +async def delete_flash_asset( + hw_type: str, + asset: str, + user: TokenPayload = Depends(require_permission("manufacturing", "delete")), +): + """Delete a single flash asset file (bootloader.bin or partitions.bin).""" + if asset not in VALID_FLASH_ASSETS: + raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}") + try: + service.delete_flash_asset(hw_type, asset) + except NotFoundError as e: + raise HTTPException(status_code=404, detail=str(e)) + await audit.log_action( + admin_user=user.email, + action="flash_asset_deleted", + detail={"hw_type": hw_type, "asset": asset}, + ) + + +class FlashAssetNoteBody(BaseModel): + note: str + + +@router.put("/flash-assets/{hw_type}/note", status_code=204) +async def set_flash_asset_note( + hw_type: str, + body: FlashAssetNoteBody, + _user: TokenPayload = Depends(require_permission("manufacturing", "edit")), +): + """Save (or overwrite) the note for a hw_type's flash asset set. + + The note is stored as note.txt next to the binary files. + Pass an empty string to clear the note. + """ + service.set_flash_asset_note(hw_type, body.note) + + @router.post("/flash-assets/{hw_type}/{asset}", status_code=204) async def upload_flash_asset( hw_type: str, diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index 93437c1..6c14d9f 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -168,16 +168,23 @@ def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = doc_data = docs[0].to_dict() or {} now = datetime.now(timezone.utc).isoformat() - history = doc_data.get("lifecycle_history") or [] + history = list(doc_data.get("lifecycle_history") or []) - # Append new lifecycle entry + # Upsert lifecycle entry — overwrite existing entry for this status if present new_entry = { "status_id": data.status.value, "date": now, "note": data.note if data.note else None, "set_by": set_by, } - history.append(new_entry) + existing_idx = next( + (i for i, e in enumerate(history) if e.get("status_id") == data.status.value), + None, + ) + if existing_idx is not None: + history[existing_idx] = new_entry + else: + history.append(new_entry) update = { "mfg_status": data.status.value, @@ -379,11 +386,68 @@ def delete_unprovisioned_devices() -> list[str]: return deleted +KNOWN_HW_TYPES = ["vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"] +FLASH_ASSET_FILES = ["bootloader.bin", "partitions.bin"] + + def _flash_asset_path(hw_type: str, asset: str) -> Path: """Return path to a flash asset (bootloader.bin or partitions.bin) for a given hw_type.""" return Path(settings.flash_assets_storage_path) / hw_type / asset +def _flash_asset_info(hw_type: str) -> dict: + """Build the asset info dict for a single hw_type by inspecting the filesystem.""" + base = Path(settings.flash_assets_storage_path) / hw_type + note_path = base / "note.txt" + note = note_path.read_text(encoding="utf-8").strip() if note_path.exists() else "" + + files = {} + for fname in FLASH_ASSET_FILES: + p = base / fname + if p.exists(): + stat = p.stat() + files[fname] = { + "exists": True, + "size_bytes": stat.st_size, + "uploaded_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + } + else: + files[fname] = {"exists": False, "size_bytes": None, "uploaded_at": None} + + return { + "hw_type": hw_type, + "bootloader": files["bootloader.bin"], + "partitions": files["partitions.bin"], + "note": note, + } + + +def list_flash_assets() -> list: + """Return asset status for all known board types plus any discovered bespoke directories.""" + base = Path(settings.flash_assets_storage_path) + results = [] + + # Always include all known hw types, even if no files uploaded yet + seen = set(KNOWN_HW_TYPES) + for hw_type in KNOWN_HW_TYPES: + results.append(_flash_asset_info(hw_type)) + + # Discover bespoke directories (anything in storage/flash_assets/ not in known list) + if base.exists(): + for entry in sorted(base.iterdir()): + if entry.is_dir() and entry.name not in seen: + seen.add(entry.name) + info = _flash_asset_info(entry.name) + info["is_bespoke"] = True + results.append(info) + + # Mark known types + for r in results: + r.setdefault("is_bespoke", False) + + return results + + def save_flash_asset(hw_type: str, asset: str, data: bytes) -> Path: """Persist a flash asset binary. asset must be 'bootloader.bin' or 'partitions.bin'.""" if asset not in ("bootloader.bin", "partitions.bin"): @@ -394,6 +458,25 @@ def save_flash_asset(hw_type: str, asset: str, data: bytes) -> Path: return path +def delete_flash_asset(hw_type: str, asset: str) -> None: + """Delete a flash asset file. Raises NotFoundError if not present.""" + path = _flash_asset_path(hw_type, asset) + if not path.exists(): + raise NotFoundError(f"Flash asset '{asset}' for '{hw_type}' not found") + path.unlink() + + +def set_flash_asset_note(hw_type: str, note: str) -> None: + """Write (or clear) the note for a hw_type's flash asset directory.""" + base = Path(settings.flash_assets_storage_path) / hw_type + base.mkdir(parents=True, exist_ok=True) + note_path = base / "note.txt" + if note.strip(): + note_path.write_text(note.strip(), encoding="utf-8") + elif note_path.exists(): + note_path.unlink() + + def get_flash_asset(hw_type: str, asset: str) -> bytes: """Load a flash asset binary. Raises NotFoundError if not uploaded yet.""" path = _flash_asset_path(hw_type, asset) diff --git a/backend/utils/emails/assets/bell_systems_horizontal_darkMode.png b/backend/utils/emails/assets/bell_systems_horizontal_darkMode.png new file mode 100644 index 0000000000000000000000000000000000000000..f80e3561dc51a81ef0e26fc3ba5e24f5995f2e14 GIT binary patch literal 16838 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

ai8z=E|@&XGiW`lX8g=MHk|(d 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 V2p1iqSGE8E002ovPDHLkV1lQ#qUZns literal 0 HcmV?d00001 diff --git a/backend/utils/emails/device_assigned_mail.py b/backend/utils/emails/device_assigned_mail.py new file mode 100644 index 0000000..610ed51 --- /dev/null +++ b/backend/utils/emails/device_assigned_mail.py @@ -0,0 +1,220 @@ +import logging +import base64 +import os +import resend +from config import settings + +logger = logging.getLogger(__name__) + +_LOGO_PATH = os.path.join(os.path.dirname(__file__), "assets", "bell_systems_horizontal_darkMode.png") +try: + with open(_LOGO_PATH, "rb") as _f: + _LOGO_B64 = base64.b64encode(_f.read()).decode() + _LOGO_SRC = f"data:image/png;base64,{_LOGO_B64}" +except Exception: + _LOGO_SRC = "" + + +def send_email(to: str, subject: str, html: str) -> None: + """Send a transactional email via Resend.""" + try: + resend.api_key = settings.resend_api_key + resend.Emails.send({ + "from": settings.email_from, + "to": to, + "subject": subject, + "html": html, + }) + logger.info("Email sent to %s — subject: %s", to, subject) + except Exception as exc: + logger.error("Failed to send email to %s: %s", to, exc) + raise + + +_OPT_IN_URL = "https://play.google.com/apps/testing/com.bellsystems.vesper" +_APP_URL = "https://play.google.com/store/apps/details?id=com.bellsystems.vesper" + + +def send_device_assigned_email( + user_email: str, + serial_number: str, + device_name: str, + user_name: str | None = None, +) -> None: + """ + Notify a user that a BellSystems device has been assigned to their account, + with links to opt in to the Vesper beta programme and download the app. + """ + greeting = f"Dear {user_name}," if user_name else "Dear valued customer," + + html = f""" + + + + + Your BellSystems Device Is Ready + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ {"BellSystems" if _LOGO_SRC else "

BELLSYSTEMS

"} +

Device Activation

+
+ +

+ {greeting} +

+ +

+ Exciting news — your + BellSystems {device_name} + has been assigned to your account and is ready to use! +

+ +

+ To get started, join the Vesper programme + and download the companion app from the Google Play Store. The app gives you full + control over your device, including scheduling, customisation, and real-time + monitoring. +

+ + + + + + + + + +
+ + Join the Vesper Programme + +
+ + Download on Google Play + +
+ + + + + + + + + +
+ Device Model
+ + BellSystems {device_name} + +
+ Serial Number
+ + {serial_number} + +
+ + + + + + + + + + + + + + + +
+ Getting Started +
+ 1   + + Click Join the Vesper Programme above to opt in via the Google Play testing programme. + +
+ 2   + + Download the Vesper app from the Google Play Store. + +
+ 3   + + Sign in with your account and your device will appear automatically. + +
+ +

+ If you have any questions or need assistance with setup, our support team is + always happy to help. +

+ +
+

+ BellSystems.gr +

+

+ Questions? Contact us at + support@bellsystems.gr +

+

+ If you did not expect this notification, please disregard this message. +

+
+
+ +""" + + send_email( + to=user_email, + subject=f"Your BellSystems {device_name} is ready — get started now!", + html=html, + ) diff --git a/backend/utils/emails/device_mfged_mail.py b/backend/utils/emails/device_mfged_mail.py new file mode 100644 index 0000000..c551698 --- /dev/null +++ b/backend/utils/emails/device_mfged_mail.py @@ -0,0 +1,155 @@ +import logging +import base64 +import os +import resend +from config import settings + +logger = logging.getLogger(__name__) + +# Embed logo as base64 so it works in any email client without a public URL +_LOGO_PATH = os.path.join(os.path.dirname(__file__), "assets", "bell_systems_horizontal_darkMode.png") +try: + with open(_LOGO_PATH, "rb") as _f: + _LOGO_B64 = base64.b64encode(_f.read()).decode() + _LOGO_SRC = f"data:image/png;base64,{_LOGO_B64}" +except Exception: + _LOGO_SRC = "" # fallback: image won't appear but email still sends + + +def send_email(to: str, subject: str, html: str) -> None: + """Send a transactional email via Resend.""" + try: + resend.api_key = settings.resend_api_key + resend.Emails.send({ + "from": settings.email_from, + "to": to, + "subject": subject, + "html": html, + }) + logger.info("Email sent to %s — subject: %s", to, subject) + except Exception as exc: + logger.error("Failed to send email to %s: %s", to, exc) + raise + + +def send_device_manufactured_email( + customer_email: str, + serial_number: str, + device_name: str, + customer_name: str | None = None, +) -> None: + """ + Notify a customer that their BellSystems device has been manufactured + and is being prepared for shipment. + """ + greeting = f"Dear {customer_name}," if customer_name else "Dear valued customer," + + html = f""" + + + + + Your BellSystems Device Has Been Manufactured + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ {"BellSystems" if _LOGO_SRC else "

BELLSYSTEMS

"} +

Manufacturing Update

+
+ +

+ {greeting} +

+ +

+ We are pleased to inform you that your + BellSystems {device_name} + has been successfully manufactured and has passed all quality checks. +

+ +

+ Your device is now being prepared for delivery. You will receive a separate + notification with tracking information once it has been dispatched. +

+ + + + + + + + + +
+ Device Model
+ + BellSystems {device_name} + +
+ Serial Number
+ + {serial_number} + +
+ +

+ Thank you for choosing BellSystems. We take great pride in crafting each device + with care and precision, and we look forward to delivering an exceptional + experience to you. +

+ +
+

+ BellSystems.gr +

+

+ Questions? Contact us at + support@bellsystems.gr +

+

+ If you did not expect this notification, please disregard this message. +

+
+
+ +""" + + send_email( + to=customer_email, + subject=f"Your BellSystems {device_name} has been manufactured", + html=html, + ) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 93aa77e..e771b92 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "esptool-js": "^0.5.7", "leaflet": "^1.9.4", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", @@ -2041,11 +2042,19 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -2193,11 +2202,21 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -2210,7 +2229,6 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, "license": "MIT" }, "node_modules/concat-map": { @@ -2307,6 +2325,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -2324,6 +2351,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dot-case": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", @@ -2342,6 +2375,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.19.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", @@ -2761,6 +2800,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2875,6 +2923,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3444,6 +3501,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", @@ -3486,7 +3552,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3532,6 +3597,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.6", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", @@ -3581,6 +3655,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3664,6 +3755,21 @@ "react-dom": ">=18" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3735,6 +3841,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3785,6 +3897,32 @@ "node": ">=0.10.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -4022,6 +4160,12 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -4032,6 +4176,26 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -4039,6 +4203,102 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs-parser/node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/yargs/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/yargs/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 9b27d82..20322e0 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "dependencies": { "esptool-js": "^0.5.7", "leaflet": "^1.9.4", + "qrcode": "^1.5.4", "react": "^19.2.0", "react-dom": "^19.2.0", "react-leaflet": "^5.0.0", diff --git a/frontend/src/cloudflash/CloudFlashPage.jsx b/frontend/src/cloudflash/CloudFlashPage.jsx index 5ac4cb4..13f9c84 100644 --- a/frontend/src/cloudflash/CloudFlashPage.jsx +++ b/frontend/src/cloudflash/CloudFlashPage.jsx @@ -21,6 +21,9 @@ const VERIFY_TIMEOUT_MS = 120_000; const FLASH_TYPE_FULL = "full_wipe"; const FLASH_TYPE_FW_ONLY = "fw_only"; +const NVS_SCHEMA_LEGACY = "legacy"; // deviceUID / hwType / hwVersion +const NVS_SCHEMA_NEW = "new"; // serial / hwFamily / hwRevision + // Fixed card height so the page doesn't jump between steps const CARD_MIN_HEIGHT = 420; @@ -345,6 +348,7 @@ function StepSelectFlashType({ firmware, onNext }) { const [serialError, setSerialError] = useState(""); const [serialValid, setSerialValid] = useState(false); const [validating, setValidating] = useState(false); + const [nvsSchema, setNvsSchema] = useState(NVS_SCHEMA_NEW); const handleSerialBlur = async () => { const trimmed = serial.trim().toUpperCase(); @@ -376,9 +380,9 @@ function StepSelectFlashType({ firmware, onNext }) { const trimmed = serial.trim().toUpperCase(); if (!trimmed) { setSerialError("Please enter the serial number from the sticker on your device."); return; } if (!serialValid) { setSerialError("Please wait for the serial number to be verified, or correct it first."); return; } - onNext({ flashType, serial: trimmed }); + onNext({ flashType, serial: trimmed, nvsSchema }); } else { - onNext({ flashType, serial: null }); + onNext({ flashType, serial: null, nvsSchema: null }); } }; @@ -426,59 +430,98 @@ function StepSelectFlashType({ firmware, onNext }) { /> - {/* Serial number field — only shown for full wipe */} + {/* Serial + NVS schema — only shown for full wipe */} {isFullWipe && (

- -

- Your serial number is printed on the sticker on the bottom of your device.

- It looks something like: BSVSPR-28F17R-PRO10R-4UAQPF. -

Enter it exactly as shown, then click outside the box to verify. -

-
- { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }} - onBlur={handleSerialBlur} - placeholder="BSXXXX-XXXXXX-XXXXXX-XXXXXX" - className="w-full px-3 py-2.5 rounded-md text-sm border font-mono pr-10" - style={{ backgroundColor: "var(--bg-input)", borderColor: inputBorderColor, color: "var(--text-primary)" }} - spellCheck={false} - disabled={validating} - /> -
- {validating && ( - - - - + {/* Two-column split: 70% serial input / 30% NVS schema selector */} +
+ + {/* Left — serial input (70%) */} + {/* Left — NVS schema selector (30%) */} +
+ +

+ Choose the format that matches your device generation. +

+
+ setNvsSchema(NVS_SCHEMA_NEW)} + /> + setNvsSchema(NVS_SCHEMA_LEGACY)} + /> +
+
+ + {/* Divider */} +
+ + {/* Right — serial input (70%) */} +
+ +

+ Found on the sticker on the bottom of your device.{" "} + {nvsSchema === NVS_SCHEMA_NEW + ? <>Looks like: BSVSPR-26C18X-STD10R-5837FG + : <>Looks like: PV25L22BP01R01 + }. Enter it exactly as shown, then click outside to verify. +

+
+ { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }} + onBlur={handleSerialBlur} + placeholder={nvsSchema === NVS_SCHEMA_NEW ? "BSXXXX-XXXXXX-XXXXXX-XXXXXX" : "PVXXXXXXXXXXXX"} + className="w-full px-3 py-2.5 rounded-md text-sm border font-mono pr-10" + style={{ backgroundColor: "var(--bg-input)", borderColor: inputBorderColor, color: "var(--text-primary)" }} + spellCheck={false} + disabled={validating} + /> +
+ {validating && ( + + + + + )} + {!validating && serialValid && ( + + + + )} + {!validating && serialError && ( + + + + )} +
+
+ {serialError && ( +

{serialError}

)} - {!validating && serialValid && ( - - - - )} - {!validating && serialError && ( - - - + {serialValid && ( +

+ ✓ Serial verified — device found in the BellSystems database. +

)}
+
- {serialError && ( -

{serialError}

- )} - {serialValid && ( -

- ✓ Serial number verified — device found in the BellSystems database. -

- )}
)} @@ -494,6 +537,51 @@ function StepSelectFlashType({ firmware, onNext }) { ); } +function NvsSchemaButton({ label, description, selected, isDefault = false, onClick }) { + return ( + + ); +} + function FlashTypeCard({ title, subtitle, description, badge, badgeColor, badgeBg, icon, selected, onClick }) { return ( + )} +
@@ -3893,6 +3943,7 @@ export default function DeviceDetail() { stats={stats} id={id} /> + setQrTarget(null)} />
); } diff --git a/frontend/src/equipment/NotesPanel.jsx b/frontend/src/equipment/NotesPanel.jsx index ab7c090..5412de0 100644 --- a/frontend/src/equipment/NotesPanel.jsx +++ b/frontend/src/equipment/NotesPanel.jsx @@ -205,7 +205,7 @@ export default function NotesPanel({ deviceId, userId, initialTab }) { {note.title}
-

{note.content}

+

{note.content}

{note.created_by && `${note.created_by} · `}{note.created_at}

@@ -260,7 +260,7 @@ export default function NotesPanel({ deviceId, userId, initialTab }) { {msg.subject || "No subject"} -

{msg.message}

+

{msg.message}

{msg.sender_name && `${msg.sender_name} · `}{msg.phone && `${msg.phone} · `}{msg.date_sent}

diff --git a/frontend/src/firmware/FirmwareManager.jsx b/frontend/src/firmware/FirmwareManager.jsx index 943d590..f9a9a91 100644 --- a/frontend/src/firmware/FirmwareManager.jsx +++ b/frontend/src/firmware/FirmwareManager.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react"; import { createPortal } from "react-dom"; import { useAuth } from "../auth/AuthContext"; import api from "../api/client"; +import FlashAssetManager from "../manufacturing/FlashAssetManager"; const BOARD_TYPES = [ { value: "vesper", label: "Vesper" }, @@ -1028,212 +1029,6 @@ function FirmwareFormModal({ initial, onClose, onSaved }) { ); } -// ── Flash Assets modal ──────────────────────────────────────────────────────── - -function FlashAssetsModal({ bespokeFirmwares, onClose }) { - const [hwType, setHwType] = useState("vesper"); - const [bespokeUid, setBespokeUid] = useState(bespokeFirmwares[0]?.bespoke_uid ?? ""); - const [bootloader, setBootloader] = useState(null); - const [partitions, setPartitions] = useState(null); - const [uploading, setUploading] = useState(false); - const [error, setError] = useState(""); - const [success, setSuccess] = useState(""); - const blRef = useRef(null); - const partRef = useRef(null); - - const effectiveHwType = hwType === "bespoke" ? bespokeUid : hwType; - - const handleUpload = async () => { - if (!bootloader && !partitions) return; - if (hwType === "bespoke" && !bespokeUid) { setError("Select a bespoke firmware first."); return; } - setError(""); setSuccess(""); setUploading(true); - const token = localStorage.getItem("access_token"); - try { - const uploads = []; - if (bootloader) uploads.push({ file: bootloader, asset: "bootloader.bin" }); - if (partitions) uploads.push({ file: partitions, asset: "partitions.bin" }); - - for (const { file, asset } of uploads) { - const formData = new FormData(); - formData.append("file", file); - const res = await fetch(`/api/manufacturing/flash-assets/${effectiveHwType}/${asset}`, { - method: "POST", - headers: { Authorization: `Bearer ${token}` }, - body: formData, - }); - if (!res.ok) { - const err = await res.json().catch(() => ({})); - throw new Error(err.detail || `Failed to upload ${asset}`); - } - } - - const label = hwType === "bespoke" - ? `bespoke / ${bespokeUid}` - : (BOARD_TYPES.find(b => b.value === hwType)?.label ?? hwType); - setSuccess(`Flash assets saved for ${label}.`); - setBootloader(null); setPartitions(null); - if (blRef.current) blRef.current.value = ""; - if (partRef.current) partRef.current.value = ""; - } catch (err) { - setError(err.message); - } finally { - setUploading(false); - } - }; - - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
-

Flash Assets

-

- Bootloader and partition table binaries per board type. Built by PlatformIO —{" "} - .pio/build/{env}/ -

-
- -
- - {/* Body */} -
- {error && ( -
- {error} -
- )} - {success && ( -
- {success} -
- )} - -
- {/* Left: board type + bespoke UID */} -
-
- - -
- - {hwType === "bespoke" && ( -
- - {bespokeFirmwares.length === 0 ? ( -

No bespoke firmware uploaded yet.

- ) : ( - - )} -
- )} -
- - {/* Bootloader + Partitions drop zones */} - {[ - { label: "Bootloader (0x1000)", file: bootloader, setFile: setBootloader, ref: blRef, hint: "bootloader.bin" }, - { label: "Partition Table (0x8000)", file: partitions, setFile: setPartitions, ref: partRef, hint: "partitions.bin" }, - ].map(({ label, file, setFile, ref, hint }) => ( -
- -
ref.current?.click()} - onDragOver={(e) => e.preventDefault()} - onDrop={(e) => { - e.preventDefault(); - const f = e.dataTransfer.files[0]; - if (f && f.name.endsWith(".bin")) { setFile(f); setSuccess(""); } - }} - style={{ - display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center", - gap: "0.5rem", padding: "1.25rem 1rem", - border: `2px dashed ${file ? "var(--btn-primary)" : "var(--border-input)"}`, - borderRadius: "0.625rem", - backgroundColor: file ? "var(--badge-blue-bg)" : "var(--bg-input)", - cursor: "pointer", transition: "all 0.15s ease", - }} - > - { setFile(e.target.files[0] || null); setSuccess(""); }} style={{ display: "none" }} /> - {file ? ( - <> - - - - - {file.name} - - {formatBytes(file.size)} - - ) : ( - <> - - - - - Click or drop {hint} - - - )} -
-
- ))} -
-
- - {/* Footer */} -
- - -
-
-
- ); -} - // ── Main component ──────────────────────────────────────────────────────────── const BOARD_TYPE_LABELS = { @@ -1352,9 +1147,6 @@ export default function FirmwareManager() { .map((k) => ALL_COLUMNS.find((c) => c.key === k)) .filter(Boolean); - // Bespoke firmwares for the flash assets modal dropdown - const bespokeFirmwares = firmware.filter((fw) => fw.hw_type === "bespoke" && fw.bespoke_uid); - function renderCell(col, fw) { switch (col.key) { case "hw_type": return ( @@ -1588,8 +1380,7 @@ export default function FirmwareManager() { )} {showFlashAssetsModal && ( - setShowFlashAssetsModal(false)} /> )} diff --git a/frontend/src/index.css b/frontend/src/index.css index 34f3014..4f7dc6c 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -85,6 +85,32 @@ --form-label-weight: 500; --form-label-tracking: 0.01em; --form-label-color: var(--text-secondary); + + /* ── Product Lifecycle status colours (pastel-dark palette) ── */ + /* Manufactured — light blue */ + --lc-manufactured-bg: #1a2e3f; + --lc-manufactured-text: #7ec8e3; + --lc-manufactured-accent: #4da8c8; + /* Flashed — yellow */ + --lc-flashed-bg: #2e2800; + --lc-flashed-text: #e8cc6a; + --lc-flashed-accent: #c9a83c; + /* Provisioned — orange */ + --lc-provisioned-bg: #2e1a00; + --lc-provisioned-text: #f0a04a; + --lc-provisioned-accent: #c97a28; + /* Sold — light green */ + --lc-sold-bg: #0e2a1a; + --lc-sold-text: #6dd49a; + --lc-sold-accent: #3daa6a; + /* Claimed — green */ + --lc-claimed-bg: #0a2416; + --lc-claimed-text: #4ade80; + --lc-claimed-accent: #22c55e; + /* Decommissioned — red */ + --lc-decommissioned-bg: #2a0a0a; + --lc-decommissioned-text: #f87171; + --lc-decommissioned-accent: #ef4444; } /* Remove number input spinners (arrows) in all browsers */ diff --git a/frontend/src/manufacturing/DeviceInventory.jsx b/frontend/src/manufacturing/DeviceInventory.jsx index 7aa4a8e..f13207e 100644 --- a/frontend/src/manufacturing/DeviceInventory.jsx +++ b/frontend/src/manufacturing/DeviceInventory.jsx @@ -30,12 +30,12 @@ const BOARD_TYPE_LABELS = { }; const STATUS_STYLES = { - manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, - flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, - provisioned: { bg: "#0a2e2a", color: "#4dd6c8" }, - sold: { bg: "#1e1036", color: "#c084fc" }, - claimed: { bg: "#2e1a00", color: "#fb923c" }, - decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" }, + manufactured: { bg: "var(--lc-manufactured-bg)", color: "var(--lc-manufactured-text)" }, + flashed: { bg: "var(--lc-flashed-bg)", color: "var(--lc-flashed-text)" }, + provisioned: { bg: "var(--lc-provisioned-bg)", color: "var(--lc-provisioned-text)" }, + sold: { bg: "var(--lc-sold-bg)", color: "var(--lc-sold-text)" }, + claimed: { bg: "var(--lc-claimed-bg)", color: "var(--lc-claimed-text)" }, + decommissioned: { bg: "var(--lc-decommissioned-bg)", color: "var(--lc-decommissioned-text)" }, }; const PROTECTED_STATUSES = ["sold", "claimed"]; diff --git a/frontend/src/manufacturing/DeviceInventoryDetail.jsx b/frontend/src/manufacturing/DeviceInventoryDetail.jsx index 5a1087e..db88770 100644 --- a/frontend/src/manufacturing/DeviceInventoryDetail.jsx +++ b/frontend/src/manufacturing/DeviceInventoryDetail.jsx @@ -8,13 +8,14 @@ const BOARD_TYPE_LABELS = { chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus", }; +// hex values mirror index.css --lc-* variables so we can build rgba() strings in JS const LIFECYCLE = [ - { key: "manufactured", label: "Manufactured", icon: "🔩", color: "#94a3b8", bg: "#1e2a3a", accent: "#64748b" }, - { key: "flashed", label: "Flashed", icon: "⚡", color: "#60a5fa", bg: "#1e3a5f", accent: "#3b82f6" }, - { key: "provisioned", label: "Provisioned", icon: "📡", color: "#34d399", bg: "#064e3b", accent: "#10b981" }, - { key: "sold", label: "Sold", icon: "📦", color: "#c084fc", bg: "#2e1065", accent: "#a855f7" }, - { key: "claimed", label: "Claimed", icon: "✅", color: "#fb923c", bg: "#431407", accent: "#f97316" }, - { key: "decommissioned", label: "Decommissioned", icon: "🗑", color: "#f87171", bg: "#450a0a", accent: "#ef4444" }, + { key: "manufactured", label: "Manufactured", icon: "🔩", color: "var(--lc-manufactured-text)", bg: "var(--lc-manufactured-bg)", accent: "var(--lc-manufactured-accent)", accentHex: "#4da8c8", bgHex: "#1a2e3f" }, + { key: "flashed", label: "Flashed", icon: "⚡", color: "var(--lc-flashed-text)", bg: "var(--lc-flashed-bg)", accent: "var(--lc-flashed-accent)", accentHex: "#c9a83c", bgHex: "#2e2800" }, + { key: "provisioned", label: "Provisioned", icon: "📡", color: "var(--lc-provisioned-text)", bg: "var(--lc-provisioned-bg)", accent: "var(--lc-provisioned-accent)", accentHex: "#c97a28", bgHex: "#2e1a00" }, + { key: "sold", label: "Sold", icon: "📦", color: "var(--lc-sold-text)", bg: "var(--lc-sold-bg)", accent: "var(--lc-sold-accent)", accentHex: "#3daa6a", bgHex: "#0e2a1a" }, + { key: "claimed", label: "Claimed", icon: "✅", color: "var(--lc-claimed-text)", bg: "var(--lc-claimed-bg)", accent: "var(--lc-claimed-accent)", accentHex: "#22c55e", bgHex: "#0a2416" }, + { key: "decommissioned", label: "Decommissioned", icon: "🗑", color: "var(--lc-decommissioned-text)", bg: "var(--lc-decommissioned-bg)", accent: "var(--lc-decommissioned-accent)", accentHex: "#ef4444", bgHex: "#2a0a0a" }, ]; const STEP_INDEX = Object.fromEntries(LIFECYCLE.map((s, i) => [s.key, i])); @@ -58,118 +59,301 @@ function Field({ label, value, mono = false }) { ); } -// ─── User Search Modal (for Claimed) ────────────────────────────────────────── +// ─── Shared 3-step assignment modal ─────────────────────────────────────────── +// mode: "claimed" (user search) | "sold" (customer search, pre-assigned) -function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) { +function AssignmentFlowModal({ mode, device, assignedCustomer, existingUsers = [], onComplete, onCancel }) { + // mode="sold": customer is already assigned (or being assigned), we just confirm + email + // mode="claimed": pick a user first + const isClaimed = mode === "claimed"; + const accentColor = isClaimed ? "var(--lc-claimed-accent)" : "var(--lc-sold-accent)"; + const accentBg = isClaimed ? "var(--lc-claimed-bg)" : "var(--lc-sold-bg)"; + const icon = isClaimed ? "✅" : "📦"; + const modeLabel = isClaimed ? "Set as Claimed" : "Set as Sold"; + + // Step 1: search & select + const [step, setStep] = useState(1); + const [selectedUser, setSelectedUser] = useState(null); // for claimed + const [keepExisting, setKeepExisting] = useState(false); + + // Step 2: email + const [sendEmail, setSendEmail] = useState(false); + const [emailAddress, setEmailAddress] = useState(""); + + // Step 3: done + const [completing, setCompleting] = useState(false); + const [done, setDone] = useState(false); + + // Search state (claimed only) const [query, setQuery] = useState(""); const [results, setResults] = useState([]); const [searching, setSearching] = useState(false); const inputRef = useRef(null); - useEffect(() => { inputRef.current?.focus(); }, []); + useEffect(() => { if (step === 1) inputRef.current?.focus(); }, [step]); const search = useCallback(async (q) => { setSearching(true); try { const data = await api.get(`/users?search=${encodeURIComponent(q)}&limit=20`); setResults(data.users || data || []); - } catch { - setResults([]); - } finally { - setSearching(false); - } + } catch { setResults([]); } + finally { setSearching(false); } }, []); useEffect(() => { + if (!isClaimed) return; const t = setTimeout(() => search(query), 300); return () => clearTimeout(t); - }, [query, search]); + }, [query, search, isClaimed]); + + // Prefill email when moving to step 2 + const advanceToEmailStep = (user, keep = false) => { + setSelectedUser(user); + setKeepExisting(keep); + // Pre-fill email from selected entity + const prefill = isClaimed + ? (user?.email || "") + : (assignedCustomer?.email || ""); + setEmailAddress(prefill); + setStep(2); + }; + + // For sold mode: step 1 is skipped (customer already known), go straight to email + useEffect(() => { + if (!isClaimed && step === 1) { + setStep(2); + } + }, [isClaimed, step]); + + // Pre-fill email for sold mode whenever assignedCustomer becomes available + useEffect(() => { + if (!isClaimed && !emailAddress) { + setEmailAddress(assignedCustomer?.email || ""); + } + }, [isClaimed, assignedCustomer]); + + const handleComplete = async () => { + setCompleting(true); + try { + await onComplete({ user: selectedUser, keepExisting, sendEmail, emailAddress }); + setDone(true); + setTimeout(() => onCancel(), 1400); + } catch { + setCompleting(false); + } + }; + + // ESC / backdrop close + useEffect(() => { + const onKey = (e) => { if (e.key === "Escape") onCancel(); }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [onCancel]); return ( -
-
-
-
- -
-
-

Set as Claimed

-

Assign a User

-
-
-

- A device is "Claimed" when a registered user has been assigned to it. Search and select the user to assign. -

+
{ if (e.target === e.currentTarget) onCancel(); }} + > +
- {/* Keep existing users option */} - {existingUsers.length > 0 && ( -
-

Already assigned

- {existingUsers.map((u) => ( - - ))} -
-
- or assign a different user -
+ {/* Header */} +
+
+ {icon} +
+
+

{modeLabel}

+

+ {done ? "Done!" : step === 1 ? (isClaimed ? "Select User" : "Select Customer") : step === 2 ? "Send Email?" : "Complete"} +

+
+ {/* Step indicator */} + {!done && ( +
+ {(isClaimed ? [1, 2, 3] : [2, 3]).map((s) => ( +
s ? accentColor + "60" : "var(--border-secondary)", + transition: "background 0.2s", + }} /> + ))}
-
- )} + )} +
+ +
+ + {/* ── DONE state ─────────────────────────────────────── */} + {done && ( +
+
+

+ {isClaimed ? "Device marked as Claimed" : "Device marked as Sold"} +

+ {sendEmail &&

Email sent to {emailAddress}

} +
+ )} + + {/* ── STEP 1: User search (claimed only) ──────────────── */} + {!done && step === 1 && isClaimed && ( + <> +

+ Search and select the user to assign to this device. +

+ + {existingUsers.length > 0 && ( +
+

Already assigned

+ {existingUsers.map((u) => ( + + ))} +
+
+ or assign a different user +
+
+
+ )} + +
+ setQuery(e.target.value)} + placeholder="Search by name or email…" + className="w-full px-3 py-2 rounded-md text-sm border" + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} + /> + {searching && } +
+
+ {results.length === 0 ? ( +

+ {searching ? "Searching…" : query ? "No users found." : "Type to search users…"} +

+ ) : results.map((u) => ( + + ))} +
+
+ +
+ + )} + + {/* ── STEP 2: Send email? ──────────────────────────────── */} + {!done && step === 2 && ( + <> + {/* Selected entity summary */} + {isClaimed && selectedUser && ( +
+ + {selectedUser.display_name || selectedUser.name || selectedUser.email} + {!keepExisting && } +
+ )} + {!isClaimed && assignedCustomer && ( +
+ + + {[assignedCustomer.name, assignedCustomer.surname].filter(Boolean).join(" ") || "Customer"} + +
+ )} + +

+ Send notification email? +

+
+ + +
+ + {sendEmail && ( +
+ + setEmailAddress(e.target.value)} + className="w-full px-3 py-2 rounded-md text-sm border" + style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} + /> +
+ )} + +
+ {isClaimed ? ( + + ) : ( + + )} + +
+ + )} -
- setQuery(e.target.value)} - placeholder="Search by name or email…" - className="w-full px-3 py-2 rounded-md text-sm border" - style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} - /> - {searching && } -
-
- {results.length === 0 ? ( -

- {searching ? "Searching…" : query ? "No users found." : "Type to search users…"} -

- ) : results.map((u) => ( - - ))} -
-
-
); } +// Keep CustomerSearchModal for the "Assign to Customer" action (not the lifecycle modal) +// ─── Customer Search Modal ───────────────────────────────────────────────── + // ─── Customer Search Modal ───────────────────────────────────────────────── function CustomerSearchModal({ onSelect, onCancel }) { @@ -351,11 +535,15 @@ function LifecycleEditModal({ entry, stepMeta, isCurrent, onSave, onDelete, onCa