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 0000000..f80e356 Binary files /dev/null and b/backend/utils/emails/assets/bell_systems_horizontal_darkMode.png differ 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