update: added assets manager and extra nvs settings on cloudflash

This commit is contained in:
2026-03-19 11:11:29 +02:00
parent d0ac4f1d91
commit 29bbaead86
17 changed files with 2369 additions and 446 deletions

View File

@@ -201,13 +201,18 @@ async def patch_lifecycle_entry(
return _doc_to_inventory_item(doc_ref.get()) 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( async def create_lifecycle_entry(
sn: str, sn: str,
body: LifecycleEntryCreate, body: LifecycleEntryCreate,
user: TokenPayload = Depends(require_permission("manufacturing", "edit")), 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 from datetime import datetime, timezone
db = get_firestore() db = get_firestore()
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream()) 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") raise HTTPException(status_code=404, detail="Device not found")
doc_ref = docs[0].reference doc_ref = docs[0].reference
data = docs[0].to_dict() or {} data = docs[0].to_dict() or {}
history = data.get("lifecycle_history") or [] history = list(data.get("lifecycle_history") or [])
new_entry = { new_entry = {
"status_id": body.status_id, "status_id": body.status_id,
"date": body.date or datetime.now(timezone.utc).isoformat(), "date": body.date or datetime.now(timezone.utc).isoformat(),
"note": body.note, "note": body.note,
"set_by": user.email, "set_by": user.email,
} }
# 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) history.append(new_entry)
doc_ref.update({"lifecycle_history": history}) doc_ref.update({"lifecycle_history": history})
from manufacturing.service import _doc_to_inventory_item from manufacturing.service import _doc_to_inventory_item
return _doc_to_inventory_item(doc_ref.get()) 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) @router.delete("/devices", status_code=200)
async def delete_unprovisioned( async def delete_unprovisioned(
user: TokenPayload = Depends(require_permission("manufacturing", "delete")), 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. # 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) @router.post("/flash-assets/{hw_type}/{asset}", status_code=204)
async def upload_flash_asset( async def upload_flash_asset(
hw_type: str, hw_type: str,

View File

@@ -168,15 +168,22 @@ def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None =
doc_data = docs[0].to_dict() or {} doc_data = docs[0].to_dict() or {}
now = datetime.now(timezone.utc).isoformat() 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 = { new_entry = {
"status_id": data.status.value, "status_id": data.status.value,
"date": now, "date": now,
"note": data.note if data.note else None, "note": data.note if data.note else None,
"set_by": set_by, "set_by": set_by,
} }
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) history.append(new_entry)
update = { update = {
@@ -379,11 +386,68 @@ def delete_unprovisioned_devices() -> list[str]:
return deleted 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: 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 to a flash asset (bootloader.bin or partitions.bin) for a given hw_type."""
return Path(settings.flash_assets_storage_path) / hw_type / asset 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: 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'.""" """Persist a flash asset binary. asset must be 'bootloader.bin' or 'partitions.bin'."""
if asset not in ("bootloader.bin", "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 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: def get_flash_asset(hw_type: str, asset: str) -> bytes:
"""Load a flash asset binary. Raises NotFoundError if not uploaded yet.""" """Load a flash asset binary. Raises NotFoundError if not uploaded yet."""
path = _flash_asset_path(hw_type, asset) path = _flash_asset_path(hw_type, asset)

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your BellSystems Device Is Ready</title>
</head>
<body style="margin:0; padding:0; background-color:#0d1117; font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#0d1117; padding:40px 16px;">
<tr>
<td align="center">
<table width="580" cellpadding="0" cellspacing="0"
style="background-color:#161b22; border-radius:12px; overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.5); max-width:580px; width:100%;
border:1px solid #30363d;">
<!-- Header with logo -->
<tr>
<td style="background-color:#0f172a; padding:32px 40px 28px; text-align:center;
border-bottom:1px solid #21262d;">
{"<img src='" + _LOGO_SRC + "' alt='BellSystems' width='180' style='display:block; margin:0 auto; max-width:180px;'>" if _LOGO_SRC else "<h1 style='color:#ffffff; margin:0; font-size:22px; font-weight:700; letter-spacing:1px;'>BELLSYSTEMS</h1>"}
<p style="color:#64748b; margin:14px 0 0; font-size:11px; letter-spacing:2.5px;
text-transform:uppercase; font-weight:600;">Device Activation</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:36px 40px 28px;">
<p style="margin:0 0 24px; font-size:16px; color:#c9d1d9; font-weight:500;">
{greeting}
</p>
<p style="margin:0 0 18px; font-size:15px; color:#8b949e; line-height:1.75;">
Exciting news — your
<strong style="color:#c9d1d9;">BellSystems {device_name}</strong>
has been assigned to your account and is ready to use!
</p>
<p style="margin:0 0 28px; font-size:15px; color:#8b949e; line-height:1.75;">
To get started, join the <strong style="color:#c9d1d9;">Vesper</strong> 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.
</p>
<!-- CTA buttons -->
<table cellpadding="0" cellspacing="0" width="100%" style="margin:0 0 32px;">
<tr>
<td align="center" style="padding-bottom:12px;">
<a href="{_OPT_IN_URL}"
style="display:inline-block; background-color:#238636; color:#ffffff;
text-decoration:none; padding:14px 32px; border-radius:8px;
font-size:14px; font-weight:700; letter-spacing:0.4px;
border:1px solid #2ea043; width:240px; text-align:center;">
Join the Vesper Programme
</a>
</td>
</tr>
<tr>
<td align="center">
<a href="{_APP_URL}"
style="display:inline-block; background-color:#1f6feb; color:#ffffff;
text-decoration:none; padding:14px 32px; border-radius:8px;
font-size:14px; font-weight:700; letter-spacing:0.4px;
border:1px solid #388bfd; width:240px; text-align:center;">
Download on Google Play
</a>
</td>
</tr>
</table>
<!-- Device info card -->
<table width="100%" cellpadding="0" cellspacing="0"
style="background:#0d1117; border:1px solid #30363d; border-radius:8px; margin-bottom:28px;">
<tr>
<td style="padding:16px 20px; border-bottom:1px solid #21262d;">
<span style="font-size:11px; color:#58a6ff; text-transform:uppercase;
letter-spacing:1.2px; font-weight:700;">Device Model</span><br>
<span style="font-size:15px; color:#c9d1d9; font-weight:600; margin-top:4px; display:block;">
BellSystems {device_name}
</span>
</td>
</tr>
<tr>
<td style="padding:16px 20px;">
<span style="font-size:11px; color:#58a6ff; text-transform:uppercase;
letter-spacing:1.2px; font-weight:700;">Serial Number</span><br>
<code style="font-size:14px; color:#79c0ff; background:#161b22;
padding:4px 10px; border-radius:4px; font-family:monospace;
border:1px solid #30363d; margin-top:6px; display:inline-block;">
{serial_number}
</code>
</td>
</tr>
</table>
<!-- How it works steps -->
<table width="100%" cellpadding="0" cellspacing="0"
style="background:#0d1117; border:1px solid #30363d; border-radius:8px; margin-bottom:28px;">
<tr>
<td style="padding:16px 20px; border-bottom:1px solid #21262d;">
<span style="font-size:11px; color:#8b949e; text-transform:uppercase;
letter-spacing:1.2px; font-weight:700;">Getting Started</span>
</td>
</tr>
<tr>
<td style="padding:14px 20px; border-bottom:1px solid #21262d;">
<span style="color:#58a6ff; font-weight:700; font-size:13px;">1 &nbsp;</span>
<span style="color:#8b949e; font-size:13px; line-height:1.6;">
Click <strong style="color:#c9d1d9;">Join the Vesper Programme</strong> above to opt in via the Google Play testing programme.
</span>
</td>
</tr>
<tr>
<td style="padding:14px 20px; border-bottom:1px solid #21262d;">
<span style="color:#58a6ff; font-weight:700; font-size:13px;">2 &nbsp;</span>
<span style="color:#8b949e; font-size:13px; line-height:1.6;">
Download the <strong style="color:#c9d1d9;">Vesper</strong> app from the Google Play Store.
</span>
</td>
</tr>
<tr>
<td style="padding:14px 20px;">
<span style="color:#58a6ff; font-weight:700; font-size:13px;">3 &nbsp;</span>
<span style="color:#8b949e; font-size:13px; line-height:1.6;">
Sign in with your account and your device will appear automatically.
</span>
</td>
</tr>
</table>
<p style="margin:0; font-size:14px; color:#6e7681; line-height:1.7;">
If you have any questions or need assistance with setup, our support team is
always happy to help.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#0d1117; border-top:1px solid #21262d;
padding:24px 40px; text-align:center;">
<p style="margin:0 0 6px; font-size:13px; color:#8b949e; font-weight:600;">
BellSystems.gr
</p>
<p style="margin:0; font-size:12px; color:#6e7681;">
Questions? Contact us at
<a href="mailto:support@bellsystems.gr"
style="color:#58a6ff; text-decoration:none;">support@bellsystems.gr</a>
</p>
<p style="margin:8px 0 0; font-size:11px; color:#484f58;">
If you did not expect this notification, please disregard this message.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
send_email(
to=user_email,
subject=f"Your BellSystems {device_name} is ready — get started now!",
html=html,
)

View File

@@ -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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Your BellSystems Device Has Been Manufactured</title>
</head>
<body style="margin:0; padding:0; background-color:#0d1117; font-family:'Helvetica Neue', Helvetica, Arial, sans-serif;">
<table width="100%" cellpadding="0" cellspacing="0" style="background-color:#0d1117; padding:40px 16px;">
<tr>
<td align="center">
<table width="580" cellpadding="0" cellspacing="0"
style="background-color:#161b22; border-radius:12px; overflow:hidden;
box-shadow:0 4px 24px rgba(0,0,0,0.5); max-width:580px; width:100%;
border:1px solid #30363d;">
<!-- Header with logo -->
<tr>
<td style="background-color:#0f172a; padding:32px 40px 28px; text-align:center;
border-bottom:1px solid #21262d;">
{"<img src='" + _LOGO_SRC + "' alt='BellSystems' width='180' style='display:block; margin:0 auto; max-width:180px;'>" if _LOGO_SRC else "<h1 style='color:#ffffff; margin:0; font-size:22px; font-weight:700; letter-spacing:1px;'>BELLSYSTEMS</h1>"}
<p style="color:#64748b; margin:14px 0 0; font-size:11px; letter-spacing:2.5px;
text-transform:uppercase; font-weight:600;">Manufacturing Update</p>
</td>
</tr>
<!-- Body -->
<tr>
<td style="padding:36px 40px 28px;">
<p style="margin:0 0 24px; font-size:16px; color:#c9d1d9; font-weight:500;">
{greeting}
</p>
<p style="margin:0 0 18px; font-size:15px; color:#8b949e; line-height:1.75;">
We are pleased to inform you that your
<strong style="color:#c9d1d9;">BellSystems {device_name}</strong>
has been successfully manufactured and has passed all quality checks.
</p>
<p style="margin:0 0 28px; font-size:15px; color:#8b949e; line-height:1.75;">
Your device is now being prepared for delivery. You will receive a separate
notification with tracking information once it has been dispatched.
</p>
<!-- Device info card -->
<table width="100%" cellpadding="0" cellspacing="0"
style="background:#0d1117; border:1px solid #30363d; border-radius:8px; margin-bottom:32px;">
<tr>
<td style="padding:16px 20px; border-bottom:1px solid #21262d;">
<span style="font-size:11px; color:#58a6ff; text-transform:uppercase;
letter-spacing:1.2px; font-weight:700;">Device Model</span><br>
<span style="font-size:15px; color:#c9d1d9; font-weight:600; margin-top:4px; display:block;">
BellSystems {device_name}
</span>
</td>
</tr>
<tr>
<td style="padding:16px 20px;">
<span style="font-size:11px; color:#58a6ff; text-transform:uppercase;
letter-spacing:1.2px; font-weight:700;">Serial Number</span><br>
<code style="font-size:14px; color:#79c0ff; background:#161b22;
padding:4px 10px; border-radius:4px; font-family:monospace;
border:1px solid #30363d; margin-top:6px; display:inline-block;">
{serial_number}
</code>
</td>
</tr>
</table>
<p style="margin:0 0 8px; font-size:14px; color:#6e7681; line-height:1.7;">
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.
</p>
</td>
</tr>
<!-- Footer -->
<tr>
<td style="background-color:#0d1117; border-top:1px solid #21262d;
padding:24px 40px; text-align:center;">
<p style="margin:0 0 6px; font-size:13px; color:#8b949e; font-weight:600;">
BellSystems.gr
</p>
<p style="margin:0; font-size:12px; color:#6e7681;">
Questions? Contact us at
<a href="mailto:support@bellsystems.gr"
style="color:#58a6ff; text-decoration:none;">support@bellsystems.gr</a>
</p>
<p style="margin:8px 0 0; font-size:11px; color:#484f58;">
If you did not expect this notification, please disregard this message.
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
send_email(
to=customer_email,
subject=f"Your BellSystems {device_name} has been manufactured",
html=html,
)

View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"esptool-js": "^0.5.7", "esptool-js": "^0.5.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"qrcode": "^1.5.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",
@@ -2041,11 +2042,19 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-convert": "^2.0.1" "color-convert": "^2.0.1"
@@ -2193,11 +2202,21 @@
"url": "https://github.com/chalk/chalk?sponsor=1" "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": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"color-name": "~1.1.4" "color-name": "~1.1.4"
@@ -2210,7 +2229,6 @@
"version": "1.1.4", "version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/concat-map": { "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": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2324,6 +2351,12 @@
"node": ">=8" "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": { "node_modules/dot-case": {
"version": "3.0.4", "version": "3.0.4",
"resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz",
@@ -2342,6 +2375,12 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/enhanced-resolve": {
"version": "5.19.0", "version": "5.19.0",
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz",
@@ -2761,6 +2800,15 @@
"node": ">=6.9.0" "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": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2875,6 +2923,15 @@
"node": ">=0.10.0" "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": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -3444,6 +3501,15 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/pako": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz",
@@ -3486,7 +3552,6 @@
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3532,6 +3597,15 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/postcss": {
"version": "8.5.6", "version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
@@ -3581,6 +3655,23 @@
"node": ">=6" "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": { "node_modules/react": {
"version": "19.2.4", "version": "19.2.4",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
@@ -3664,6 +3755,21 @@
"react-dom": ">=18" "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": { "node_modules/resolve-from": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3735,6 +3841,12 @@
"semver": "bin/semver.js" "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": { "node_modules/set-cookie-parser": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
@@ -3785,6 +3897,32 @@
"node": ">=0.10.0" "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": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4022,6 +4160,12 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
@@ -4032,6 +4176,26 @@
"node": ">=0.10.0" "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
@@ -4039,6 +4203,102 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",

View File

@@ -12,6 +12,7 @@
"dependencies": { "dependencies": {
"esptool-js": "^0.5.7", "esptool-js": "^0.5.7",
"leaflet": "^1.9.4", "leaflet": "^1.9.4",
"qrcode": "^1.5.4",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
"react-leaflet": "^5.0.0", "react-leaflet": "^5.0.0",

View File

@@ -21,6 +21,9 @@ const VERIFY_TIMEOUT_MS = 120_000;
const FLASH_TYPE_FULL = "full_wipe"; const FLASH_TYPE_FULL = "full_wipe";
const FLASH_TYPE_FW_ONLY = "fw_only"; 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 // Fixed card height so the page doesn't jump between steps
const CARD_MIN_HEIGHT = 420; const CARD_MIN_HEIGHT = 420;
@@ -345,6 +348,7 @@ function StepSelectFlashType({ firmware, onNext }) {
const [serialError, setSerialError] = useState(""); const [serialError, setSerialError] = useState("");
const [serialValid, setSerialValid] = useState(false); const [serialValid, setSerialValid] = useState(false);
const [validating, setValidating] = useState(false); const [validating, setValidating] = useState(false);
const [nvsSchema, setNvsSchema] = useState(NVS_SCHEMA_NEW);
const handleSerialBlur = async () => { const handleSerialBlur = async () => {
const trimmed = serial.trim().toUpperCase(); const trimmed = serial.trim().toUpperCase();
@@ -376,9 +380,9 @@ function StepSelectFlashType({ firmware, onNext }) {
const trimmed = serial.trim().toUpperCase(); const trimmed = serial.trim().toUpperCase();
if (!trimmed) { setSerialError("Please enter the serial number from the sticker on your device."); return; } 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; } 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 { } else {
onNext({ flashType, serial: null }); onNext({ flashType, serial: null, nvsSchema: null });
} }
}; };
@@ -426,19 +430,55 @@ function StepSelectFlashType({ firmware, onNext }) {
/> />
</div> </div>
{/* Serial number field — only shown for full wipe */} {/* Serial + NVS schema — only shown for full wipe */}
{isFullWipe && ( {isFullWipe && (
<div <div
className="rounded-lg border p-4 mb-4" className="rounded-lg border mb-4 overflow-hidden"
style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border-primary)" }} style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border-primary)" }}
> >
{/* Two-column split: 70% serial input / 30% NVS schema selector */}
<div className="flex" style={{ minHeight: 0 }}>
{/* Left — serial input (70%) */}
{/* Left — NVS schema selector (30%) */}
<div className="p-4" style={{ flex: "0 0 35%" }}>
<label className="block text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
NVS Identity Format
</label>
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
Choose the format that matches your device generation.
</p>
<div className="flex flex-col gap-2">
<NvsSchemaButton
label="Current Generation"
description="serial · hwFamily · hwRevision"
selected={nvsSchema === NVS_SCHEMA_NEW}
isDefault
onClick={() => setNvsSchema(NVS_SCHEMA_NEW)}
/>
<NvsSchemaButton
label="Legacy Generation"
description="deviceUID · hwType · hwVersion"
selected={nvsSchema === NVS_SCHEMA_LEGACY}
onClick={() => setNvsSchema(NVS_SCHEMA_LEGACY)}
/>
</div>
</div>
{/* Divider */}
<div style={{ width: 1, backgroundColor: "var(--border-primary)", flexShrink: 0, margin: "16px 0" }} />
{/* Right — serial input (70%) */}
<div className="p-4" style={{ flex: 1 }}>
<label className="block text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}> <label className="block text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
Device Serial Number Device Serial Number
</label> </label>
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}> <p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
Your serial number is printed on the sticker on the bottom of your device.<br></br> Found on the sticker on the bottom of your device.{" "}
It looks something like: <span className="font-mono" style={{ color: "var(--text-secondary)" }}>BSVSPR-28F17R-PRO10R-4UAQPF</span>. {nvsSchema === NVS_SCHEMA_NEW
<br></br>Enter it exactly as shown, then click outside the box to verify. ? <>Looks like: <span className="font-mono" style={{ color: "var(--text-secondary)" }}>BSVSPR-26C18X-STD10R-5837FG</span></>
: <>Looks like: <span className="font-mono" style={{ color: "var(--text-secondary)" }}>PV25L22BP01R01</span></>
}. Enter it exactly as shown, then click outside to verify.
</p> </p>
<div className="relative"> <div className="relative">
<input <input
@@ -446,7 +486,7 @@ function StepSelectFlashType({ firmware, onNext }) {
value={serial} value={serial}
onChange={(e) => { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }} onChange={(e) => { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }}
onBlur={handleSerialBlur} onBlur={handleSerialBlur}
placeholder="BSXXXX-XXXXXX-XXXXXX-XXXXXX" 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" 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)" }} style={{ backgroundColor: "var(--bg-input)", borderColor: inputBorderColor, color: "var(--text-primary)" }}
spellCheck={false} spellCheck={false}
@@ -476,10 +516,13 @@ function StepSelectFlashType({ firmware, onNext }) {
)} )}
{serialValid && ( {serialValid && (
<p className="text-xs mt-2" style={{ color: "var(--accent)" }}> <p className="text-xs mt-2" style={{ color: "var(--accent)" }}>
✓ Serial number verified — device found in the BellSystems database. ✓ Serial verified — device found in the BellSystems database.
</p> </p>
)} )}
</div> </div>
</div>
</div>
)} )}
<div className="flex justify-end mt-auto"> <div className="flex justify-end mt-auto">
@@ -494,6 +537,51 @@ function StepSelectFlashType({ firmware, onNext }) {
); );
} }
function NvsSchemaButton({ label, description, selected, isDefault = false, onClick }) {
return (
<button
type="button"
onClick={onClick}
className="w-full rounded-md border p-2.5 text-left transition-all cursor-pointer"
style={{
backgroundColor: selected ? "var(--bg-card)" : "transparent",
borderColor: selected ? "var(--accent)" : "var(--border-primary)",
boxShadow: selected ? "0 0 8px 2px rgba(116,184,22,0.18)" : "none",
}}
>
<div className="flex items-start gap-2">
<div
className="flex-shrink-0 w-3.5 h-3.5 rounded-full border-2 flex items-center justify-center mt-0.5"
style={{
borderColor: selected ? "var(--accent)" : "var(--border-primary)",
backgroundColor: selected ? "var(--accent)" : "transparent",
}}
>
{selected && <div style={{ width: 5, height: 5, borderRadius: "50%", backgroundColor: "var(--bg-primary)" }} />}
</div>
<div className="min-w-0">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-xs font-semibold" style={{ color: selected ? "var(--accent)" : "var(--text-secondary)" }}>
{label}
</span>
{isDefault && (
<span
className="text-[9px] font-semibold px-1 py-0.5 rounded"
style={{ backgroundColor: "rgba(116,184,22,0.15)", color: "var(--accent)" }}
>
DEFAULT
</span>
)}
</div>
<p className="text-[10px] font-mono mt-0.5 leading-snug" style={{ color: "var(--text-muted)" }}>
{description}
</p>
</div>
</div>
</button>
);
}
function FlashTypeCard({ title, subtitle, description, badge, badgeColor, badgeBg, icon, selected, onClick }) { function FlashTypeCard({ title, subtitle, description, badge, badgeColor, badgeBg, icon, selected, onClick }) {
return ( return (
<button <button
@@ -545,7 +633,7 @@ function FlashTypeCard({ title, subtitle, description, badge, badgeColor, badgeB
// ─── Step 4: Connect & Flash ────────────────────────────────────────────────── // ─── Step 4: Connect & Flash ──────────────────────────────────────────────────
function StepFlash({ firmware, flashType, serial, onDone }) { function StepFlash({ firmware, flashType, serial, nvsSchema, onDone }) {
const [phase, setPhase] = useState("connect"); // connect | flashing | done const [phase, setPhase] = useState("connect"); // connect | flashing | done
const [connecting, setConnecting] = useState(false); const [connecting, setConnecting] = useState(false);
const [portName, setPortName] = useState(""); const [portName, setPortName] = useState("");
@@ -615,6 +703,7 @@ function StepFlash({ firmware, flashType, serial, onDone }) {
serial_number: serial, serial_number: serial,
hw_type: firmware.hw_type, hw_type: firmware.hw_type,
hw_revision: "1.0", hw_revision: "1.0",
nvs_schema: nvsSchema,
}); });
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`); appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
} }
@@ -946,6 +1035,20 @@ function DisabledScreen() {
); );
} }
// ─── Scroll Unlocker ─────────────────────────────────────────────────────────
// The app shell sets html/body/#root to overflow:hidden.
// This wrapper temporarily lifts that so CloudFlash can scroll freely.
function ScrollUnlocker({ children }) {
useEffect(() => {
const targets = [document.documentElement, document.body, document.getElementById("root")];
const prev = targets.map((el) => el && el.style.overflow);
targets.forEach((el) => el && (el.style.overflow = "auto"));
return () => targets.forEach((el, i) => el && (el.style.overflow = prev[i] || ""));
}, []);
return children;
}
// ─── Main CloudFlash Page ───────────────────────────────────────────────────── // ─── Main CloudFlash Page ─────────────────────────────────────────────────────
export default function CloudFlashPage() { export default function CloudFlashPage() {
@@ -957,6 +1060,7 @@ export default function CloudFlashPage() {
const [firmware, setFirmware] = useState(null); // { hw_type, hw_type_label, channel, version, download_url } const [firmware, setFirmware] = useState(null); // { hw_type, hw_type_label, channel, version, download_url }
const [flashType, setFlashType] = useState(null); // FLASH_TYPE_FULL | FLASH_TYPE_FW_ONLY const [flashType, setFlashType] = useState(null); // FLASH_TYPE_FULL | FLASH_TYPE_FW_ONLY
const [serial, setSerial] = useState(null); // user-provided serial (full wipe only) const [serial, setSerial] = useState(null); // user-provided serial (full wipe only)
const [nvsSchema, setNvsSchema] = useState(null); // NVS_SCHEMA_NEW | NVS_SCHEMA_LEGACY
// Check feature gate // Check feature gate
useEffect(() => { useEffect(() => {
@@ -972,6 +1076,7 @@ export default function CloudFlashPage() {
setFirmware(null); setFirmware(null);
setFlashType(null); setFlashType(null);
setSerial(null); setSerial(null);
setNvsSchema(null);
}; };
if (gateLoading) { if (gateLoading) {
@@ -985,9 +1090,10 @@ export default function CloudFlashPage() {
if (!enabled) return <DisabledScreen />; if (!enabled) return <DisabledScreen />;
return ( return (
<ScrollUnlocker>
<div <div
className="min-h-screen flex flex-col items-center justify-start py-10 px-4" className="flex flex-col items-center justify-center py-10 px-4"
style={{ backgroundColor: "var(--bg-primary)" }} style={{ minHeight: "100vh", backgroundColor: "var(--bg-primary)" }}
> >
{/* Brand header */} {/* Brand header */}
<div className="mb-6"> <div className="mb-6">
@@ -1019,9 +1125,10 @@ export default function CloudFlashPage() {
{step === 3 && firmware && ( {step === 3 && firmware && (
<StepSelectFlashType <StepSelectFlashType
firmware={firmware} firmware={firmware}
onNext={({ flashType: ft, serial: sn }) => { onNext={({ flashType: ft, serial: sn, nvsSchema: ns }) => {
setFlashType(ft); setFlashType(ft);
setSerial(sn); setSerial(sn);
setNvsSchema(ns);
setStep(4); setStep(4);
}} }}
/> />
@@ -1031,6 +1138,7 @@ export default function CloudFlashPage() {
firmware={firmware} firmware={firmware}
flashType={flashType} flashType={flashType}
serial={serial} serial={serial}
nvsSchema={nvsSchema}
onDone={() => setStep(5)} onDone={() => setStep(5)}
/> />
)} )}
@@ -1049,5 +1157,6 @@ export default function CloudFlashPage() {
© {new Date().getFullYear()} BellSystems · CloudFlash v1.0 © {new Date().getFullYear()} BellSystems · CloudFlash v1.0
</p> </p>
</div> </div>
</ScrollUnlocker>
); );
} }

View File

@@ -3113,7 +3113,7 @@ export default function CustomerDetail() {
{/* ── Create TXT Modal ── */} {/* ── Create TXT Modal ── */}
{showCreateTxt && ( {showCreateTxt && (
<div onClick={() => !createTxtSaving && setShowCreateTxt(false)} <div
style={{ position: "fixed", inset: 0, zIndex: 1000, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center" }}> style={{ position: "fixed", inset: 0, zIndex: 1000, backgroundColor: "rgba(0,0,0,0.6)", display: "flex", alignItems: "center", justifyContent: "center" }}>
<div onClick={e => e.stopPropagation()} <div onClick={e => e.stopPropagation()}
style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 24, width: 420, boxShadow: "0 16px 48px rgba(0,0,0,0.35)", display: "flex", flexDirection: "column", gap: 14 }}> style={{ backgroundColor: "var(--bg-card)", borderRadius: 10, padding: 24, width: 420, boxShadow: "0 16px 48px rgba(0,0,0,0.35)", display: "flex", flexDirection: "column", gap: 14 }}>

View File

@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import { useParams, useNavigate } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import QRCode from "qrcode";
import { MapContainer, TileLayer, Marker } from "react-leaflet"; import { MapContainer, TileLayer, Marker } from "react-leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
import L from "leaflet"; import L from "leaflet";
@@ -198,6 +199,32 @@ function LogLevelSelect({ value, onChange }) {
); );
} }
function QrModal({ value, onClose }) {
const canvasRef = useRef(null);
useEffect(() => {
if (!value || !canvasRef.current) return;
QRCode.toCanvas(canvasRef.current, value, { width: 220, margin: 2, color: { dark: "#e3e5ea", light: "#1f2937" } });
}, [value]);
useEffect(() => {
const onKey = (e) => { if (e.key === "Escape") onClose(); };
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onClose]);
if (!value) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={onClose}>
<div className="rounded-xl border p-5 shadow-2xl flex flex-col items-center gap-3"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onClick={(e) => e.stopPropagation()}>
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{value}</p>
<canvas ref={canvasRef} style={{ borderRadius: 8 }} />
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Click outside or press ESC to close</p>
</div>
</div>
);
}
function SectionModal({ open, title, onCancel, onSave, saving, disabled, size = "max-w-lg", children }) { function SectionModal({ open, title, onCancel, onSave, saving, disabled, size = "max-w-lg", children }) {
if (!open) return null; if (!open) return null;
return ( return (
@@ -1151,6 +1178,12 @@ const LOG_LEVEL_STYLES = {
function parseFirestoreDate(str) { function parseFirestoreDate(str) {
if (!str) return null; if (!str) return null;
// Handle Firestore Timestamp objects serialised as {_seconds, _nanoseconds} or {seconds, nanoseconds}
if (typeof str === "object") {
const secs = str._seconds ?? str.seconds;
if (typeof secs === "number") return new Date(secs * 1000);
return null;
}
const cleaned = str.replace(" at ", " ").replace("UTC+0000", "UTC").replace(/UTC\+(\d{4})/, "UTC"); const cleaned = str.replace(" at ", " ").replace("UTC+0000", "UTC").replace(/UTC\+(\d{4})/, "UTC");
const d = new Date(cleaned); const d = new Date(cleaned);
return isNaN(d.getTime()) ? null : d; return isNaN(d.getTime()) ? null : d;
@@ -1711,6 +1744,7 @@ export default function DeviceDetail() {
const [editingBacklight, setEditingBacklight] = useState(false); const [editingBacklight, setEditingBacklight] = useState(false);
const [editingSubscription, setEditingSubscription] = useState(false); const [editingSubscription, setEditingSubscription] = useState(false);
const [editingWarranty, setEditingWarranty] = useState(false); const [editingWarranty, setEditingWarranty] = useState(false);
const [qrTarget, setQrTarget] = useState(null);
useEffect(() => { useEffect(() => {
loadData(); loadData();
@@ -2201,7 +2235,23 @@ export default function DeviceDetail() {
<div className="db-row"> <div className="db-row">
<div className="db-info-field"> <div className="db-info-field">
<span className="db-info-label">SERIAL NUMBER</span> <span className="db-info-label">SERIAL NUMBER</span>
<span className="db-info-value">{device.serial_number || device.device_id || "-"}</span> <span className="db-info-value" style={{ display: "inline-flex", alignItems: "center", gap: 8 }}>
{device.serial_number || device.device_id || "-"}
{(device.serial_number || device.device_id) && (
<button
type="button"
title="Show QR Code"
onClick={() => setQrTarget(device.serial_number || device.device_id)}
className="cursor-pointer hover:opacity-70 transition-opacity"
style={{ color: "var(--text-muted)", background: "none", border: "none", padding: 0, lineHeight: 1 }}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>
<path d="M14 14h.01M14 18h.01M18 14h.01M18 18h.01M21 14h.01M21 21h.01M14 21h.01"/>
</svg>
</button>
)}
</span>
</div> </div>
</div> </div>
<div className="db-row"> <div className="db-row">
@@ -3893,6 +3943,7 @@ export default function DeviceDetail() {
stats={stats} stats={stats}
id={id} id={id}
/> />
<QrModal value={qrTarget} onClose={() => setQrTarget(null)} />
</div> </div>
); );
} }

View File

@@ -205,7 +205,7 @@ export default function NotesPanel({ deviceId, userId, initialTab }) {
{note.title} {note.title}
</span> </span>
</div> </div>
<p className="text-xs" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{note.content}</p> <p className="text-xs" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden", whiteSpace: "pre-wrap" }}>{note.content}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}> <p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{note.created_by && `${note.created_by} · `}{note.created_at} {note.created_by && `${note.created_by} · `}{note.created_at}
</p> </p>
@@ -260,7 +260,7 @@ export default function NotesPanel({ deviceId, userId, initialTab }) {
{msg.subject || "No subject"} {msg.subject || "No subject"}
</span> </span>
</div> </div>
<p className="text-xs" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden" }}>{msg.message}</p> <p className="text-xs" style={{ color: "var(--text-muted)", display: "-webkit-box", WebkitLineClamp: 3, WebkitBoxOrient: "vertical", overflow: "hidden", whiteSpace: "pre-wrap" }}>{msg.message}</p>
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}> <p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
{msg.sender_name && `${msg.sender_name} · `}{msg.phone && `${msg.phone} · `}{msg.date_sent} {msg.sender_name && `${msg.sender_name} · `}{msg.phone && `${msg.phone} · `}{msg.date_sent}
</p> </p>

View File

@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import api from "../api/client"; import api from "../api/client";
import FlashAssetManager from "../manufacturing/FlashAssetManager";
const BOARD_TYPES = [ const BOARD_TYPES = [
{ value: "vesper", label: "Vesper" }, { 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 (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full mx-4 flex flex-col"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
maxWidth: "900px",
}}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div className="flex items-center justify-between px-6 pt-5 pb-4" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Flash Assets</h3>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Bootloader and partition table binaries per board type. Built by PlatformIO {" "}
<span style={{ fontFamily: "monospace" }}>.pio/build/&#123;env&#125;/</span>
</p>
</div>
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}></button>
</div>
{/* Body */}
<div className="px-6 py-5">
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{success && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success-text)", color: "var(--success-text)" }}>
{success}
</div>
)}
<div style={{ display: "grid", gridTemplateColumns: "200px 1fr 1fr", gap: "1.5rem", alignItems: "start" }}>
{/* Left: board type + bespoke UID */}
<div style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Board Type</label>
<select
value={hwType}
onChange={(e) => { setHwType(e.target.value); setSuccess(""); }}
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)" }}
>
{BOARD_TYPES.map((bt) => (
<option key={bt.value} value={bt.value}>{bt.label}</option>
))}
</select>
</div>
{hwType === "bespoke" && (
<div>
<label className="block text-xs font-medium mb-1" style={{ color: "var(--text-muted)" }}>Bespoke Firmware</label>
{bespokeFirmwares.length === 0 ? (
<p className="text-xs" style={{ color: "var(--text-muted)" }}>No bespoke firmware uploaded yet.</p>
) : (
<select
value={bespokeUid}
onChange={(e) => { setBespokeUid(e.target.value); setSuccess(""); }}
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)" }}
>
{bespokeFirmwares.map((fw) => (
<option key={fw.bespoke_uid} value={fw.bespoke_uid}>{fw.bespoke_uid}</option>
))}
</select>
)}
</div>
)}
</div>
{/* 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 }) => (
<div key={hint} style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
<label className="block text-xs font-medium" style={{ color: "var(--text-muted)" }}>{label}</label>
<div
onClick={() => 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",
}}
>
<input ref={ref} type="file" accept=".bin" onChange={(e) => { setFile(e.target.files[0] || null); setSuccess(""); }} style={{ display: "none" }} />
{file ? (
<>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--btn-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
<span style={{ fontSize: "0.75rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>
{file.name}
</span>
<span style={{ fontSize: "0.68rem", color: "var(--text-muted)" }}>{formatBytes(file.size)}</span>
</>
) : (
<>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
<span style={{ fontSize: "0.75rem", color: "var(--text-muted)", textAlign: "center" }}>
Click or drop <span style={{ fontFamily: "monospace" }}>{hint}</span>
</span>
</>
)}
</div>
</div>
))}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4" style={{ borderTop: "1px solid var(--border-secondary)" }}>
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Close
</button>
<button
onClick={handleUpload}
disabled={uploading || (!bootloader && !partitions) || (hwType === "bespoke" && !bespokeUid)}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading…" : "Save Assets"}
</button>
</div>
</div>
</div>
);
}
// ── Main component ──────────────────────────────────────────────────────────── // ── Main component ────────────────────────────────────────────────────────────
const BOARD_TYPE_LABELS = { const BOARD_TYPE_LABELS = {
@@ -1352,9 +1147,6 @@ export default function FirmwareManager() {
.map((k) => ALL_COLUMNS.find((c) => c.key === k)) .map((k) => ALL_COLUMNS.find((c) => c.key === k))
.filter(Boolean); .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) { function renderCell(col, fw) {
switch (col.key) { switch (col.key) {
case "hw_type": return ( case "hw_type": return (
@@ -1588,8 +1380,7 @@ export default function FirmwareManager() {
)} )}
{showFlashAssetsModal && ( {showFlashAssetsModal && (
<FlashAssetsModal <FlashAssetManager
bespokeFirmwares={bespokeFirmwares}
onClose={() => setShowFlashAssetsModal(false)} onClose={() => setShowFlashAssetsModal(false)}
/> />
)} )}

View File

@@ -85,6 +85,32 @@
--form-label-weight: 500; --form-label-weight: 500;
--form-label-tracking: 0.01em; --form-label-tracking: 0.01em;
--form-label-color: var(--text-secondary); --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 */ /* Remove number input spinners (arrows) in all browsers */

View File

@@ -30,12 +30,12 @@ const BOARD_TYPE_LABELS = {
}; };
const STATUS_STYLES = { const STATUS_STYLES = {
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" }, manufactured: { bg: "var(--lc-manufactured-bg)", color: "var(--lc-manufactured-text)" },
flashed: { bg: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }, flashed: { bg: "var(--lc-flashed-bg)", color: "var(--lc-flashed-text)" },
provisioned: { bg: "#0a2e2a", color: "#4dd6c8" }, provisioned: { bg: "var(--lc-provisioned-bg)", color: "var(--lc-provisioned-text)" },
sold: { bg: "#1e1036", color: "#c084fc" }, sold: { bg: "var(--lc-sold-bg)", color: "var(--lc-sold-text)" },
claimed: { bg: "#2e1a00", color: "#fb923c" }, claimed: { bg: "var(--lc-claimed-bg)", color: "var(--lc-claimed-text)" },
decommissioned:{ bg: "var(--danger-bg)", color: "var(--danger-text)" }, decommissioned: { bg: "var(--lc-decommissioned-bg)", color: "var(--lc-decommissioned-text)" },
}; };
const PROTECTED_STATUSES = ["sold", "claimed"]; const PROTECTED_STATUSES = ["sold", "claimed"];

View File

@@ -8,13 +8,14 @@ const BOARD_TYPE_LABELS = {
chronos: "Chronos", chronos_pro: "Chronos Pro", agnus_mini: "Agnus Mini", agnus: "Agnus", 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 = [ const LIFECYCLE = [
{ key: "manufactured", label: "Manufactured", icon: "🔩", color: "#94a3b8", bg: "#1e2a3a", accent: "#64748b" }, { 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: "#60a5fa", bg: "#1e3a5f", accent: "#3b82f6" }, { 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: "#34d399", bg: "#064e3b", accent: "#10b981" }, { 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: "#c084fc", bg: "#2e1065", accent: "#a855f7" }, { 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: "#fb923c", bg: "#431407", accent: "#f97316" }, { 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: "#f87171", bg: "#450a0a", accent: "#ef4444" }, { 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])); const STEP_INDEX = Object.fromEntries(LIFECYCLE.map((s, i) => [s.key, i]));
@@ -58,63 +59,168 @@ 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 [query, setQuery] = useState("");
const [results, setResults] = useState([]); const [results, setResults] = useState([]);
const [searching, setSearching] = useState(false); const [searching, setSearching] = useState(false);
const inputRef = useRef(null); const inputRef = useRef(null);
useEffect(() => { inputRef.current?.focus(); }, []); useEffect(() => { if (step === 1) inputRef.current?.focus(); }, [step]);
const search = useCallback(async (q) => { const search = useCallback(async (q) => {
setSearching(true); setSearching(true);
try { try {
const data = await api.get(`/users?search=${encodeURIComponent(q)}&limit=20`); const data = await api.get(`/users?search=${encodeURIComponent(q)}&limit=20`);
setResults(data.users || data || []); setResults(data.users || data || []);
} catch { } catch { setResults([]); }
setResults([]); finally { setSearching(false); }
} finally {
setSearching(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => {
if (!isClaimed) return;
const t = setTimeout(() => search(query), 300); const t = setTimeout(() => search(query), 300);
return () => clearTimeout(t); 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 ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: "rgba(0,0,0,0.65)" }}> <div
<div className="rounded-xl border p-6 w-full max-w-md shadow-2xl" style={{ backgroundColor: "var(--bg-card)", borderColor: "#f97316aa" }}> className="fixed inset-0 z-50 flex items-center justify-center"
<div className="flex items-center gap-3 mb-4"> style={{ backgroundColor: "rgba(0,0,0,0.68)" }}
<div className="w-8 h-8 rounded-lg flex items-center justify-center" style={{ backgroundColor: "#431407", border: "1px solid #f9731640" }}> onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
<span></span> >
<div className="rounded-xl border shadow-2xl w-full max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: accentColor + "60" }}>
{/* Header */}
<div className="flex items-center gap-3 px-5 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{ backgroundColor: accentBg, border: `1px solid ${accentColor}40` }}>
<span>{icon}</span>
</div> </div>
<div> <div className="flex-1">
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: "#fb923c" }}>Set as Claimed</p> <p className="text-xs font-semibold uppercase tracking-wider" style={{ color: accentColor }}>{modeLabel}</p>
<h2 className="text-sm font-bold" style={{ color: "var(--text-heading)" }}>Assign a User</h2> <h2 className="text-sm font-bold" style={{ color: "var(--text-heading)" }}>
{done ? "Done!" : step === 1 ? (isClaimed ? "Select User" : "Select Customer") : step === 2 ? "Send Email?" : "Complete"}
</h2>
</div> </div>
{/* Step indicator */}
{!done && (
<div className="flex items-center gap-1.5 flex-shrink-0">
{(isClaimed ? [1, 2, 3] : [2, 3]).map((s) => (
<div key={s} style={{
width: 6, height: 6, borderRadius: "50%",
backgroundColor: step === s ? accentColor : step > s ? accentColor + "60" : "var(--border-secondary)",
transition: "background 0.2s",
}} />
))}
</div> </div>
)}
</div>
<div className="px-5 py-4">
{/* ── DONE state ─────────────────────────────────────── */}
{done && (
<div className="flex flex-col items-center py-6 gap-3">
<div style={{
width: 52, height: 52, borderRadius: "50%",
backgroundColor: accentBg,
border: `2px solid ${accentColor}`,
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: "1.6rem",
animation: "lc-pop 0.3s ease",
}}></div>
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>
{isClaimed ? "Device marked as Claimed" : "Device marked as Sold"}
</p>
{sendEmail && <p className="text-xs" style={{ color: "var(--text-muted)" }}>Email sent to {emailAddress}</p>}
</div>
)}
{/* ── STEP 1: User search (claimed only) ──────────────── */}
{!done && step === 1 && isClaimed && (
<>
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}> <p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
A device is "Claimed" when a registered user has been assigned to it. Search and select the user to assign. Search and select the user to assign to this device.
</p> </p>
{/* Keep existing users option */}
{existingUsers.length > 0 && ( {existingUsers.length > 0 && (
<div className="mb-3"> <div className="mb-3">
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Already assigned</p> <p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Already assigned</p>
{existingUsers.map((u) => ( {existingUsers.map((u) => (
<button <button key={u.uid} type="button" onClick={() => advanceToEmailStep(u, true)}
key={u.uid}
type="button"
onClick={() => onSelect(u, true)}
className="w-full text-left px-3 py-2.5 text-sm rounded-lg border mb-1.5 flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity" className="w-full text-left px-3 py-2.5 text-sm rounded-lg border mb-1.5 flex items-center gap-3 cursor-pointer hover:opacity-80 transition-opacity"
style={{ backgroundColor: "#431407", borderColor: "#f9731640", color: "var(--text-primary)" }} style={{ backgroundColor: accentBg, borderColor: accentColor + "40", color: "var(--text-primary)" }}>
>
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" <div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
style={{ backgroundColor: "#f9731630", color: "#fb923c" }}> style={{ backgroundColor: accentColor + "30", color: accentColor }}>
{(u.display_name || u.email || "U")[0].toUpperCase()} {(u.display_name || u.email || "U")[0].toUpperCase()}
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -122,8 +228,8 @@ function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
{u.email && u.display_name && <span className="text-xs block" style={{ color: "var(--text-muted)" }}>{u.email}</span>} {u.email && u.display_name && <span className="text-xs block" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
</div> </div>
<span className="text-xs flex-shrink-0 px-2 py-0.5 rounded" <span className="text-xs flex-shrink-0 px-2 py-0.5 rounded"
style={{ backgroundColor: "#f9731620", color: "#fb923c", border: "1px solid #f9731640" }}> style={{ backgroundColor: accentColor + "20", color: accentColor, border: `1px solid ${accentColor}40` }}>
Keep Keep &amp; Proceed
</span> </span>
</button> </button>
))} ))}
@@ -135,11 +241,8 @@ function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
</div> </div>
)} )}
<div style={{ position: "relative" }} className="mb-3"> <div style={{ position: "relative" }} className="mb-2">
<input <input ref={inputRef} type="text" value={query}
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search by name or email…" placeholder="Search by name or email…"
className="w-full px-3 py-2 rounded-md text-sm border" className="w-full px-3 py-2 rounded-md text-sm border"
@@ -147,13 +250,13 @@ function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
/> />
{searching && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>} {searching && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}></span>}
</div> </div>
<div className="rounded-md border overflow-y-auto" style={{ borderColor: "var(--border-secondary)", maxHeight: 220, minHeight: 48 }}> <div className="rounded-md border overflow-y-auto mb-4" style={{ borderColor: "var(--border-secondary)", maxHeight: 200, minHeight: 44 }}>
{results.length === 0 ? ( {results.length === 0 ? (
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}> <p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
{searching ? "Searching…" : query ? "No users found." : "Type to search users…"} {searching ? "Searching…" : query ? "No users found." : "Type to search users…"}
</p> </p>
) : results.map((u) => ( ) : results.map((u) => (
<button key={u.id || u.uid} type="button" onClick={() => onSelect(u, false)} <button key={u.id || u.uid} type="button" onClick={() => advanceToEmailStep(u, false)}
className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors" className="w-full text-left px-3 py-2.5 text-sm hover:opacity-80 cursor-pointer border-b last:border-b-0 transition-colors"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}> style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}>
<span className="font-medium">{u.display_name || u.name || u.email || u.id}</span> <span className="font-medium">{u.display_name || u.name || u.email || u.id}</span>
@@ -161,15 +264,96 @@ function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
</button> </button>
))} ))}
</div> </div>
<div className="flex gap-3 justify-end mt-4"> <div className="flex justify-end">
<button onClick={onCancel} className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer" <button onClick={onCancel} className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>Cancel</button> style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>Cancel</button>
</div> </div>
</>
)}
{/* ── STEP 2: Send email? ──────────────────────────────── */}
{!done && step === 2 && (
<>
{/* Selected entity summary */}
{isClaimed && selectedUser && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border mb-4 text-sm"
style={{ backgroundColor: accentBg, borderColor: accentColor + "40" }}>
<span style={{ color: accentColor }}></span>
<span style={{ color: "var(--text-primary)" }}>{selectedUser.display_name || selectedUser.name || selectedUser.email}</span>
{!keepExisting && <button type="button" onClick={() => setStep(1)}
className="ml-auto text-xs hover:opacity-80 cursor-pointer" style={{ color: "var(--text-muted)" }}>Change</button>}
</div>
)}
{!isClaimed && assignedCustomer && (
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border mb-4 text-sm"
style={{ backgroundColor: accentBg, borderColor: accentColor + "40" }}>
<span style={{ color: accentColor }}></span>
<span style={{ color: "var(--text-primary)" }}>
{[assignedCustomer.name, assignedCustomer.surname].filter(Boolean).join(" ") || "Customer"}
</span>
</div>
)}
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: "var(--text-muted)" }}>
Send notification email?
</p>
<div className="flex gap-3 mb-4">
<button type="button" onClick={() => setSendEmail(false)}
className="flex-1 py-3 rounded-lg border text-sm font-medium cursor-pointer transition-all"
style={{
backgroundColor: !sendEmail ? accentBg : "var(--bg-input)",
borderColor: !sendEmail ? accentColor : "var(--border-input)",
color: !sendEmail ? accentColor : "var(--text-secondary)",
}}>
Do Not Send
</button>
<button type="button" onClick={() => setSendEmail(true)}
className="flex-1 py-3 rounded-lg border text-sm font-medium cursor-pointer transition-all"
style={{
backgroundColor: sendEmail ? accentBg : "var(--bg-input)",
borderColor: sendEmail ? accentColor : "var(--border-input)",
color: sendEmail ? accentColor : "var(--text-secondary)",
}}>
Send Email
</button>
</div>
{sendEmail && (
<div className="mb-4">
<label className="block text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Email address</label>
<input type="email" value={emailAddress} onChange={(e) => 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)" }}
/>
</div>
)}
<div className="flex gap-3 justify-between">
{isClaimed ? (
<button onClick={() => setStep(1)} className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}> Back</button>
) : (
<button onClick={onCancel} className="px-4 py-1.5 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>Cancel</button>
)}
<button onClick={handleComplete} disabled={completing}
className="px-5 py-1.5 text-sm rounded-md font-semibold hover:opacity-90 cursor-pointer disabled:opacity-50"
style={{ backgroundColor: accentColor, color: "#fff" }}>
{completing ? "Processing…" : "Complete →"}
</button>
</div>
</>
)}
</div>
</div> </div>
</div> </div>
); );
} }
// Keep CustomerSearchModal for the "Assign to Customer" action (not the lifecycle modal)
// ─── Customer Search Modal ─────────────────────────────────────────────────
// ─── Customer Search Modal ───────────────────────────────────────────────── // ─── Customer Search Modal ─────────────────────────────────────────────────
function CustomerSearchModal({ onSelect, onCancel }) { function CustomerSearchModal({ onSelect, onCancel }) {
@@ -351,11 +535,15 @@ function LifecycleEditModal({ entry, stepMeta, isCurrent, onSave, onDelete, onCa
<textarea <textarea
value={note} value={note}
onChange={(e) => setNote(e.target.value)} onChange={(e) => setNote(e.target.value)}
rows={3} maxLength={150}
rows={4}
placeholder="Add a note about this step…" placeholder="Add a note about this step…"
className="w-full px-3 py-2 rounded-lg text-sm border resize-none" className="w-full px-3 py-2 rounded-lg text-sm border resize-vertical"
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }} style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
/> />
<p className="text-xs mt-1 text-right" style={{ color: note.length > 130 ? "var(--warning-text, #f59e0b)" : "var(--text-muted)" }}>
{note.length}/150
</p>
</div> </div>
{/* Delete confirm */} {/* Delete confirm */}
@@ -434,21 +622,22 @@ function LifecycleLadder({ device, canEdit, onStatusChange, onEditEntry, statusE
<div key={step.key} style={{ display: "flex", alignItems: "stretch", gap: 0 }}> <div key={step.key} style={{ display: "flex", alignItems: "stretch", gap: 0 }}>
{/* Left rail */} {/* Left rail */}
<div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 44, flexShrink: 0 }}> <div style={{ display: "flex", flexDirection: "column", alignItems: "center", width: 44, flexShrink: 0 }}>
{/* Step circle — clicking handled on the card now */} {/* Step circle — vertically centred with card via flex */}
<div <div
style={{ style={{
marginTop: 3,
width: 38, width: 38,
height: 38, height: 38,
borderRadius: "50%", borderRadius: "50%",
border: `2px solid ${isCurrent ? step.accent : isPast ? step.accent + "70" : isHovered && canEdit ? step.accent + "50" : "var(--border-secondary)"}`, border: `2px solid ${isCurrent ? step.accentHex : isPast ? `${step.accentHex}90` : isHovered && canEdit ? `${step.accentHex}60` : `${step.accentHex}30`}`,
backgroundColor: isCurrent ? step.bg : isPast ? step.bg : isHovered && canEdit ? step.bg + "60" : "var(--bg-primary)", backgroundColor: isCurrent ? step.bg : isPast ? step.bg : isHovered && canEdit ? `${step.bgHex}99` : `${step.bgHex}55`,
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
fontSize: "1rem", fontSize: "1rem",
flexShrink: 0, flexShrink: 0,
opacity: isFuture && !isHovered ? 0.35 : 1, opacity: isFuture && !isHovered ? 0.35 : 1,
boxShadow: isCurrent ? `0 0 0 3px ${step.accent}25, 0 0 14px ${step.accent}25` : "none", boxShadow: isCurrent ? `0 0 0 3px ${step.accentHex}40, 0 0 14px ${step.accentHex}30` : "none",
position: "relative", position: "relative",
transition: "all 0.2s", transition: "all 0.2s",
pointerEvents: "none", pointerEvents: "none",
@@ -464,15 +653,15 @@ function LifecycleLadder({ device, canEdit, onStatusChange, onEditEntry, statusE
<span style={{ fontSize: isCurrent ? "1rem" : "0.85rem" }}>{step.icon}</span> <span style={{ fontSize: isCurrent ? "1rem" : "0.85rem" }}>{step.icon}</span>
</div> </div>
{/* Connector line */} {/* Connector line — always visible, colour-coded for past, muted for future */}
{!isLast && ( {!isLast && (
<div style={{ <div style={{
width: 2, width: 2,
flex: 1, flexGrow: 1,
minHeight: 12, minHeight: 8,
background: isPast background: isPast || isCurrent
? `linear-gradient(to bottom, ${step.accent}90, ${LIFECYCLE[i+1].accent}40)` ? `linear-gradient(to bottom, ${step.accentHex}90, ${LIFECYCLE[i+1].accentHex}50)`
: "var(--border-secondary)", : `linear-gradient(to bottom, ${step.accentHex}30, ${LIFECYCLE[i+1].accentHex}20)`,
margin: "2px 0", margin: "2px 0",
borderRadius: 1, borderRadius: 1,
transition: "background 0.3s", transition: "background 0.3s",
@@ -505,20 +694,23 @@ function LifecycleLadder({ device, canEdit, onStatusChange, onEditEntry, statusE
padding: "10px 12px", padding: "10px 12px",
borderRadius: 10, borderRadius: 10,
border: `1px solid ${ border: `1px solid ${
isCurrent ? step.accent + "60" isCurrent
: isHovered && canEdit ? step.accent + "35" ? `${step.accentHex}cc`
: isPast ? step.accent + "25" : isHovered && canEdit
: "var(--border-secondary)" ? `${step.accentHex}80`
: isPast
? `${step.accentHex}40`
: `${step.accentHex}30`
}`, }`,
backgroundColor: isCurrent backgroundColor: isCurrent
? step.bg ? step.bg
: isHovered && canEdit : isHovered && canEdit
? step.bg + "55" ? `${step.bgHex}cc`
: isPast : isPast
? step.bg + "35" ? `${step.bgHex}66`
: "transparent", : `${step.bgHex}55`,
opacity: isFuture && !isHovered ? 0.38 : 1, opacity: isFuture && !isHovered ? 0.5 : isPast && !isHovered ? 0.6 : 1,
transition: "all 0.18s", transition: "border-color 0.18s, background-color 0.18s, opacity 0.18s",
cursor: canEdit ? "pointer" : "default", cursor: canEdit ? "pointer" : "default",
position: "relative", position: "relative",
display: "flex", display: "flex",
@@ -566,7 +758,7 @@ function LifecycleLadder({ device, canEdit, onStatusChange, onEditEntry, statusE
{/* Note */} {/* Note */}
{entry?.note && ( {entry?.note && (
<p style={{ fontSize: "0.7rem", color: "var(--text-secondary)", margin: 0, marginTop: 1, fontStyle: "italic", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}> <p style={{ fontSize: "0.7rem", color: "var(--text-secondary)", margin: 0, marginTop: 1, fontStyle: "italic", whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
"{entry.note}" "{entry.note}"
</p> </p>
)} )}
@@ -640,13 +832,14 @@ export default function DeviceInventoryDetail() {
const [assignError, setAssignError] = useState(""); const [assignError, setAssignError] = useState("");
const [showCustomerModal, setShowCustomerModal] = useState(false); const [showCustomerModal, setShowCustomerModal] = useState(false);
// Claimed user assignment
const [showUserModal, setShowUserModal] = useState(false);
const [userSaving, setUserSaving] = useState(false); const [userSaving, setUserSaving] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
// Unified assignment flow modal
const [assignFlowMode, setAssignFlowMode] = useState(null); // null | "sold" | "claimed"
// Resolved user info for the assigned users section // Resolved user info for the assigned users section
const [resolvedUsers, setResolvedUsers] = useState([]); // [{uid, display_name, email}] const [resolvedUsers, setResolvedUsers] = useState([]); // [{uid, display_name, email}]
@@ -657,7 +850,30 @@ export default function DeviceInventoryDetail() {
setLoading(true); setLoading(true);
setError(""); setError("");
try { try {
const data = await api.get(`/manufacturing/devices/${sn}`); let data = await api.get(`/manufacturing/devices/${sn}`);
// Auto-upgrade to "claimed" if user_list has entries but status is not yet claimed
console.log("[auto-claimed] check:", { status: data.mfg_status, user_list: data.user_list });
if (
data.user_list?.length > 0 &&
data.mfg_status !== "claimed" &&
data.mfg_status !== "decommissioned"
) {
try {
data = await api.request(`/manufacturing/devices/${sn}/status`, {
method: "PATCH",
body: JSON.stringify({
status: "claimed",
force_claimed: true,
note: "Auto-updated to Claimed: a user was found in user_list",
}),
});
} catch (upgradeErr) {
// Non-fatal — still show the device even if auto-upgrade fails
console.warn("[auto-claimed] upgrade failed:", upgradeErr?.message || upgradeErr);
}
}
setDevice(data); setDevice(data);
if (data.customer_id) fetchCustomerDetails(data.customer_id); if (data.customer_id) fetchCustomerDetails(data.customer_id);
if (data.user_list?.length) fetchResolvedUsers(data.user_list); if (data.user_list?.length) fetchResolvedUsers(data.user_list);
@@ -695,17 +911,21 @@ export default function DeviceInventoryDetail() {
// ── Status change ────────────────────────────────────────────────────────── // ── Status change ──────────────────────────────────────────────────────────
const handleStatusChange = async (newStatus) => { const handleStatusChange = async (newStatus) => {
if (newStatus === device?.mfg_status) return; // already current if (newStatus === device?.mfg_status) return;
if (newStatus === "claimed") { if (newStatus === "claimed") {
// Open user-assign flow: user must pick a user first setAssignFlowMode("claimed");
setShowUserModal(true);
return; return;
} }
if (newStatus === "sold" && !device?.customer_id) { if (newStatus === "sold" && !device?.customer_id) {
// No customer yet — open customer search first, then sold flow
setShowCustomerModal(true); setShowCustomerModal(true);
return; return;
} }
if (newStatus === "sold" && device?.customer_id) {
setAssignFlowMode("sold");
return;
}
setStatusError(""); setStatusError("");
setStatusSaving(true); setStatusSaving(true);
try { try {
@@ -721,34 +941,65 @@ export default function DeviceInventoryDetail() {
} }
}; };
// ── Claimed: assign user then set status ─────────────────────────────────── // ── Unified assignment flow completion ─────────────────────────────────────
const handleClaimedUserSelect = async (user, keepExisting = false) => { const handleAssignFlowComplete = async ({ user, keepExisting, sendEmail }) => {
setShowUserModal(false);
setUserSaving(true);
setStatusError(""); setStatusError("");
if (assignFlowMode === "claimed") {
setUserSaving(true);
try { try {
// 1. Add user to device's user_list (skip if keeping the existing user)
if (!keepExisting) { if (!keepExisting) {
await api.request(`/devices/${device.id}/users`, { await api.request(`/devices/${device.id}/user-list`, {
method: "POST", method: "POST",
body: JSON.stringify({ user_id: user.id || user.uid }), body: JSON.stringify({ user_id: user.id || user.uid }),
}); });
} }
// 2. Set status to claimed (backend guard now passes since user_list is non-empty)
const updated = await api.request(`/manufacturing/devices/${sn}/status`, { const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ status: "claimed", force_claimed: true }), body: JSON.stringify({ status: "claimed", force_claimed: true }),
}); });
setDevice(updated); setDevice(updated);
if (updated.user_list?.length) fetchResolvedUsers(updated.user_list); if (updated.user_list?.length) fetchResolvedUsers(updated.user_list);
if (sendEmail) {
try {
await api.request(`/manufacturing/devices/${sn}/email/assigned`, { method: "POST" });
} catch (emailErr) {
setStatusError(`Status updated, but email failed: ${emailErr.message}`);
}
}
} catch (err) { } catch (err) {
setStatusError(err.message); setStatusError(err.message);
throw err; // so modal stays open on error
} finally { } finally {
setUserSaving(false); setUserSaving(false);
setAssignFlowMode(null);
}
} else {
// sold
setStatusSaving(true);
try {
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
method: "PATCH",
body: JSON.stringify({ status: "sold" }),
});
setDevice(updated);
if (sendEmail) {
try {
await api.request(`/manufacturing/devices/${sn}/email/manufactured`, { method: "POST" });
} catch (emailErr) {
setStatusError(`Status updated, but email failed: ${emailErr.message}`);
}
}
} catch (err) {
setStatusError(err.message);
throw err;
} finally {
setStatusSaving(false);
setAssignFlowMode(null);
}
} }
}; };
// ── Customer assign ──────────────────────────────────────────────────────── // ── Customer assign (from "Assign to Customer" section) ───────────────────
const handleSelectCustomer = async (customer) => { const handleSelectCustomer = async (customer) => {
setShowCustomerModal(false); setShowCustomerModal(false);
setAssignError(""); setAssignError("");
@@ -760,6 +1011,8 @@ export default function DeviceInventoryDetail() {
}); });
setDevice(updated); setDevice(updated);
setAssignedCustomer(customer); setAssignedCustomer(customer);
// After assigning customer, open the sold flow modal
setAssignFlowMode("sold");
} catch (err) { } catch (err) {
setAssignError(err.message); setAssignError(err.message);
} finally { } finally {
@@ -926,6 +1179,12 @@ export default function DeviceInventoryDetail() {
<Field label="HW Version" value={formatHwVersion(device?.hw_version)} /> <Field label="HW Version" value={formatHwVersion(device?.hw_version)} />
<Field label="Created At" value={formatDate(device?.created_at)} /> <Field label="Created At" value={formatDate(device?.created_at)} />
{device?.device_name && <Field label="Device Name" value={device.device_name} />} {device?.device_name && <Field label="Device Name" value={device.device_name} />}
<div>
<p className="ui-field-label mb-0.5">Document ID</p>
<p className="text-sm font-mono" style={{ color: "var(--text-more-muted)" }}>
{device?.id || "—"}
</p>
</div>
</div> </div>
</div> </div>
@@ -944,23 +1203,33 @@ export default function DeviceInventoryDetail() {
)} )}
{device?.customer_id ? ( {device?.customer_id ? (
<div className="flex items-center gap-3"> <div
<div className="flex-1 flex items-center gap-3 px-3 py-2 rounded-md border" role="button"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}> tabIndex={0}
onClick={(e) => { if (!e.target.closest("[data-reassign-btn]")) navigate(`/crm/customers/${device.customer_id}`); }}
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !e.target.closest("[data-reassign-btn]")) navigate(`/crm/customers/${device.customer_id}`); }}
className="flex items-center gap-3 px-3 py-2 rounded-md border transition-colors"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)", cursor: "pointer" }}
onMouseEnter={(e) => e.currentTarget.style.borderColor = "var(--border-primary)"}
onMouseLeave={(e) => e.currentTarget.style.borderColor = "var(--border-secondary)"}
>
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" <div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}> style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
{(assignedCustomer?.name || "?")[0].toUpperCase()} {(assignedCustomer?.name || "?")[0].toUpperCase()}
</div> </div>
<div className="min-w-0"> <div className="min-w-0 flex-1">
<p className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}> <p className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
{[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(" ") || "Customer"} {[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(" ") || "Customer"}
</p> </p>
{assignedCustomer?.organization && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.organization}</p>} {assignedCustomer?.organization && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.organization}</p>}
{assignedCustomer?.city && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.city}</p>} {assignedCustomer?.city && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.city}</p>}
</div> </div>
</div> <button
<button type="button" onClick={() => setShowCustomerModal(true)} disabled={assignSaving} data-reassign-btn
className="px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-80 disabled:opacity-50" type="button"
onClick={(e) => { e.stopPropagation(); setShowCustomerModal(true); }}
disabled={assignSaving}
className="px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-80 disabled:opacity-50 flex-shrink-0"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}> style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}>
Reassign Reassign
</button> </button>
@@ -998,10 +1267,17 @@ export default function DeviceInventoryDetail() {
const initials = (displayName || email || uid)[0]?.toUpperCase() || "U"; const initials = (displayName || email || uid)[0]?.toUpperCase() || "U";
return ( return (
<div key={uid} <div key={uid}
className="flex items-center gap-3 px-3 py-2 rounded-md border" role="button"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}> tabIndex={0}
onClick={() => navigate(`/users/${uid}`)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") navigate(`/users/${uid}`); }}
className="flex items-center gap-3 px-3 py-2 rounded-md border transition-colors"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)", cursor: "pointer" }}
onMouseEnter={(e) => e.currentTarget.style.borderColor = "var(--border-primary)"}
onMouseLeave={(e) => e.currentTarget.style.borderColor = "var(--border-secondary)"}
>
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0" <div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
style={{ backgroundColor: "#431407", color: "#fb923c" }}> style={{ backgroundColor: "var(--lc-claimed-bg)", color: "var(--lc-claimed-text)" }}>
{initials} {initials}
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
@@ -1011,6 +1287,9 @@ export default function DeviceInventoryDetail() {
} }
{email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{email}</p>} {email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{email}</p>}
</div> </div>
<svg className="w-3.5 h-3.5 flex-shrink-0 ml-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--text-muted)" }}>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
</div> </div>
); );
})} })}
@@ -1066,11 +1345,14 @@ export default function DeviceInventoryDetail() {
{showCustomerModal && ( {showCustomerModal && (
<CustomerSearchModal onSelect={handleSelectCustomer} onCancel={() => setShowCustomerModal(false)} /> <CustomerSearchModal onSelect={handleSelectCustomer} onCancel={() => setShowCustomerModal(false)} />
)} )}
{showUserModal && ( {assignFlowMode && (
<UserSearchModal <AssignmentFlowModal
onSelect={handleClaimedUserSelect} mode={assignFlowMode}
onCancel={() => setShowUserModal(false)} device={device}
assignedCustomer={assignedCustomer}
existingUsers={resolvedUsers} existingUsers={resolvedUsers}
onComplete={handleAssignFlowComplete}
onCancel={() => setAssignFlowMode(null)}
/> />
)} )}
{showDeleteModal && ( {showDeleteModal && (

View File

@@ -0,0 +1,793 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { createPortal } from "react-dom";
import { useAuth } from "../auth/AuthContext";
import api from "../api/client";
// ── Constants ─────────────────────────────────────────────────────────────────
const KNOWN_BOARD_TYPES = [
{ value: "vesper", label: "Vesper", family: "vesper" },
{ value: "vesper_plus", label: "Vesper+", family: "vesper" },
{ value: "vesper_pro", label: "Vesper Pro", family: "vesper" },
{ value: "agnus", label: "Agnus", family: "agnus" },
{ value: "agnus_mini", label: "Agnus Mini", family: "agnus" },
{ value: "chronos", label: "Chronos", family: "chronos" },
{ value: "chronos_pro", label: "Chronos Pro", family: "chronos" },
];
const BOARD_LABELS = Object.fromEntries(KNOWN_BOARD_TYPES.map((b) => [b.value, b.label]));
const FAMILY_ACCENT = {
vesper: "#3b82f6",
agnus: "#f59e0b",
chronos: "#ef4444",
bespoke: "#a855f7",
};
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatBytes(bytes) {
if (!bytes) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
}
function formatDate(iso) {
if (!iso) return null;
try {
return new Date(iso).toLocaleString("en-US", {
year: "numeric", month: "short", day: "numeric",
hour: "2-digit", minute: "2-digit",
});
} catch {
return iso;
}
}
function boardLabel(hw_type, is_bespoke) {
if (is_bespoke) return hw_type;
return BOARD_LABELS[hw_type] || hw_type;
}
function familyOf(hw_type, is_bespoke) {
if (is_bespoke) return "bespoke";
const bt = KNOWN_BOARD_TYPES.find((b) => b.value === hw_type);
return bt?.family ?? "vesper";
}
// ── Icons ─────────────────────────────────────────────────────────────────────
function IconCheck() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);
}
function IconMissing() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
);
}
function IconTrash() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6" />
<path d="M10 11v6" /><path d="M14 11v6" /><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
</svg>
);
}
function IconUpload() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" />
</svg>
);
}
function IconNote() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" />
<line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" />
</svg>
);
}
function IconRefresh() {
return (
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" />
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
</svg>
);
}
// ── Asset file pill (used inside each board card) ────────────────────────────
function AssetPill({ label, info, canDelete, onDelete, onUpload }) {
const exists = info?.exists;
return (
<div
style={{
display: "flex", alignItems: "center", gap: "0.5rem",
padding: "0.375rem 0.625rem",
borderRadius: "0.375rem",
border: `1px solid ${exists ? "var(--border-secondary)" : "var(--danger)"}`,
backgroundColor: exists ? "var(--bg-secondary)" : "var(--danger-bg)",
minWidth: 0,
}}
>
<span style={{ color: exists ? "var(--success-text)" : "var(--danger-text)", flexShrink: 0 }}>
{exists ? <IconCheck /> : <IconMissing />}
</span>
<div style={{ flex: 1, minWidth: 0 }}>
<div className="font-mono" style={{ fontSize: "0.7rem", color: exists ? "var(--text-secondary)" : "var(--danger-text)", fontWeight: 600 }}>
{label}
</div>
{exists ? (
<div style={{ fontSize: "0.65rem", color: "var(--text-muted)", marginTop: "1px" }}>
{formatBytes(info.size_bytes)} · {formatDate(info.uploaded_at)}
</div>
) : (
<div style={{ fontSize: "0.65rem", color: "var(--danger-text)", marginTop: "1px", opacity: 0.8 }}>
Not uploaded
</div>
)}
</div>
<div style={{ display: "flex", gap: "0.25rem", flexShrink: 0 }}>
<button
onClick={onUpload}
title={exists ? "Re-upload" : "Upload"}
className="flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity rounded"
style={{
width: "24px", height: "24px",
backgroundColor: "var(--bg-card-hover)",
border: "1px solid var(--border-primary)",
color: "var(--text-muted)",
}}
>
<IconUpload />
</button>
{exists && canDelete && (
<button
onClick={onDelete}
title="Delete"
className="flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity rounded"
style={{
width: "24px", height: "24px",
backgroundColor: "var(--danger-bg)",
border: "1px solid var(--danger)",
color: "var(--danger-text)",
}}
>
<IconTrash />
</button>
)}
</div>
</div>
);
}
// ── Inline Upload Modal ───────────────────────────────────────────────────────
function UploadModal({ hwType, assetName, onClose, onSaved }) {
const [file, setFile] = useState(null);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState("");
const fileInputRef = useRef(null);
const handleUpload = async () => {
if (!file) return;
setError(""); setUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
const token = localStorage.getItem("access_token");
const res = await fetch(`/api/manufacturing/flash-assets/${hwType}/${assetName}`, {
method: "POST",
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Upload failed");
}
onSaved();
} catch (err) {
setError(err.message);
} finally {
setUploading(false);
}
};
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full mx-4 flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "480px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-4 pb-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>
Upload {assetName}
</h3>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Board: <span className="font-mono">{hwType}</span> overwrites existing file
</p>
</div>
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}></button>
</div>
<div className="px-5 py-4" style={{ display: "flex", flexDirection: "column", gap: "1rem" }}>
{error && (
<div className="text-xs rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f && f.name.endsWith(".bin")) setFile(f);
else if (f) setError("Only .bin files are accepted.");
}}
style={{
display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "center",
gap: "0.5rem", padding: "2rem 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",
}}
>
<input
ref={fileInputRef}
type="file"
accept=".bin"
onChange={(e) => {
const f = e.target.files[0];
if (f && !f.name.endsWith(".bin")) { setError("Only .bin files are accepted."); return; }
setFile(f || null); setError("");
}}
style={{ display: "none" }}
/>
{file ? (
<>
<span style={{ color: "var(--btn-primary)" }}><IconCheck /></span>
<span style={{ fontSize: "0.8rem", fontWeight: 600, color: "var(--badge-blue-text)", textAlign: "center", wordBreak: "break-all" }}>{file.name}</span>
<span style={{ fontSize: "0.7rem", color: "var(--text-muted)" }}>{formatBytes(file.size)}</span>
</>
) : (
<>
<span style={{ color: "var(--text-muted)" }}><IconUpload /></span>
<span style={{ fontSize: "0.8rem", color: "var(--text-muted)", textAlign: "center" }}>
Click or drop <span className="font-mono">{assetName}</span>
</span>
</>
)}
</div>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
Cancel
</button>
<button
onClick={handleUpload}
disabled={!file || uploading}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{uploading ? "Uploading…" : "Upload"}
</button>
</div>
</div>
</div>
);
}
// ── Note Editor Modal ─────────────────────────────────────────────────────────
function NoteModal({ hwType, currentNote, onClose, onSaved }) {
const [note, setNote] = useState(currentNote || "");
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const handleSave = async () => {
setSaving(true); setError("");
try {
await api.put(`/manufacturing/flash-assets/${hwType}/note`, { note });
onSaved(note);
} catch (err) {
setError(err.message || "Failed to save note");
} finally {
setSaving(false);
}
};
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full mx-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "480px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 pt-4 pb-3" style={{ borderBottom: "1px solid var(--border-secondary)" }}>
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Asset Note</h3>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
<span className="font-mono">{hwType}</span> stored as <span className="font-mono">note.txt</span> alongside the binaries
</p>
</div>
<button onClick={onClose} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}></button>
</div>
<div className="px-5 py-4">
{error && (
<div className="text-xs rounded-md p-3 mb-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<textarea
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="e.g. Built from PlatformIO env:vesper_plus_v2, commit abc1234. Partition layout changed — reflash required."
rows={5}
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)", resize: "vertical" }}
/>
<p className="text-xs mt-1.5" style={{ color: "var(--text-muted)" }}>Leave blank to clear the note.</p>
</div>
<div className="flex items-center justify-end gap-3 px-5 py-3" style={{ borderTop: "1px solid var(--border-secondary)" }}>
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving}
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{saving ? "Saving…" : "Save Note"}
</button>
</div>
</div>
</div>
);
}
// ── Delete Confirm Modal ──────────────────────────────────────────────────────
function DeleteConfirmModal({ hwType, assetName, onClose, onConfirmed }) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState("");
const handleDelete = async () => {
setDeleting(true); setError("");
try {
const token = localStorage.getItem("access_token");
const res = await fetch(`/api/manufacturing/flash-assets/${hwType}/${assetName}`, {
method: "DELETE",
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.detail || "Delete failed");
}
onConfirmed();
} catch (err) {
setError(err.message);
setDeleting(false);
}
};
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={onClose}
>
<div
className="rounded-lg border p-6 max-w-sm w-full mx-4"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Delete Flash Asset</h3>
<p className="text-sm mb-1" style={{ color: "var(--text-secondary)" }}>
Delete <span className="font-mono" style={{ color: "var(--text-primary)" }}>{assetName}</span> for{" "}
<span className="font-mono" style={{ color: "var(--text-primary)" }}>{hwType}</span>?
</p>
<p className="text-xs mb-5" style={{ color: "var(--text-muted)" }}>
This cannot be undone. Flashing will fail until you re-upload this file.
</p>
{error && (
<div className="text-xs rounded-md p-2 mb-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div className="flex gap-3">
<button
onClick={handleDelete}
disabled={deleting}
className="px-4 py-2 text-sm rounded-md hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-50"
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
>
{deleting ? "Deleting…" : "Delete"}
</button>
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}>
Cancel
</button>
</div>
</div>
</div>
);
}
// ── Board Card ────────────────────────────────────────────────────────────────
function BoardCard({ entry, canDelete, canEdit, onUpload, onDelete, onNote, onRefresh }) {
const { hw_type, bootloader, partitions, note, is_bespoke } = entry;
const family = familyOf(hw_type, is_bespoke);
const accent = FAMILY_ACCENT[family] || FAMILY_ACCENT.vesper;
const label = boardLabel(hw_type, is_bespoke);
const hasBootloader = bootloader?.exists;
const hasPartitions = partitions?.exists;
const hasAll = hasBootloader && hasPartitions;
const hasNone = !hasBootloader && !hasPartitions;
const statusColor = hasAll ? "var(--success-text)" : hasNone ? "var(--danger-text)" : "#f59e0b";
const statusBg = hasAll ? "var(--success-bg)" : hasNone ? "var(--danger-bg)" : "#2a1a00";
const statusLabel = hasAll ? "Ready" : hasNone ? "Missing" : "Partial";
return (
<div
className="rounded-lg border"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
borderLeft: `3px solid ${accent}`,
display: "flex", flexDirection: "column",
gap: "0",
}}
>
{/* Header */}
<div className="px-4 pt-3 pb-2" style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.5rem" }}>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", minWidth: 0, flexWrap: "wrap" }}>
<span className="text-sm font-semibold" style={{ color: "var(--text-heading)", lineHeight: 1 }}>{label}</span>
{is_bespoke && (
<span className="text-xs px-1.5 py-0.5 rounded font-medium" style={{ backgroundColor: "#2a0a3a", color: "#a855f7", border: "1px solid #a855f7", lineHeight: 1 }}>
bespoke
</span>
)}
<span style={{ color: "var(--text-muted)", opacity: 0.4, lineHeight: 1, fontSize: "0.75rem" }}>·</span>
<span className="font-mono text-xs" style={{ color: "var(--text-muted)", opacity: 0.7, lineHeight: 1 }}>{hw_type}</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
<span
className="text-xs px-2 py-0.5 rounded-full font-medium"
style={{ backgroundColor: statusBg, color: statusColor }}
>
{statusLabel}
</span>
{canEdit && (
<button
onClick={() => onNote(entry)}
title={note ? "Edit note" : "Add note"}
className="flex items-center gap-1 px-2 py-1 text-xs rounded cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: note ? "var(--bg-secondary)" : "transparent",
border: `1px solid ${note ? "var(--border-secondary)" : "transparent"}`,
color: note ? "var(--text-secondary)" : "var(--text-muted)",
}}
>
<IconNote />
{note ? "Note" : "Add note"}
</button>
)}
</div>
</div>
{/* Note preview */}
{note && (
<div className="px-4 pb-2">
<p className="text-xs" style={{ color: "var(--text-muted)", fontStyle: "italic", lineHeight: 1.5 }}>
{note}
</p>
</div>
)}
{/* Asset pills */}
<div className="px-4 pb-3" style={{ display: "flex", flexDirection: "column", gap: "0.4rem" }}>
<AssetPill
label="bootloader.bin"
info={bootloader}
canDelete={canDelete}
onUpload={() => onUpload(hw_type, "bootloader.bin")}
onDelete={() => onDelete(hw_type, "bootloader.bin")}
/>
<AssetPill
label="partitions.bin"
info={partitions}
canDelete={canDelete}
onUpload={() => onUpload(hw_type, "partitions.bin")}
onDelete={() => onDelete(hw_type, "partitions.bin")}
/>
</div>
</div>
);
}
// ── Main Component ────────────────────────────────────────────────────────────
export default function FlashAssetManager({ onClose }) {
const { hasPermission } = useAuth();
const canDelete = hasPermission("manufacturing", "delete");
const canEdit = hasPermission("manufacturing", "edit");
const [assets, setAssets] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [filter, setFilter] = useState("all"); // "all" | "ready" | "partial" | "missing"
// Modal state
const [uploadTarget, setUploadTarget] = useState(null); // { hwType, assetName }
const [deleteTarget, setDeleteTarget] = useState(null); // { hwType, assetName }
const [noteTarget, setNoteTarget] = useState(null); // entry
const fetchAssets = useCallback(async () => {
setLoading(true); setError("");
try {
const data = await api.get("/manufacturing/flash-assets");
setAssets(data.assets || []);
} catch (err) {
setError(err.message || "Failed to load flash assets");
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchAssets(); }, [fetchAssets]);
// Filter
const filteredAssets = assets.filter((e) => {
const hasAll = e.bootloader?.exists && e.partitions?.exists;
const hasNone = !e.bootloader?.exists && !e.partitions?.exists;
if (filter === "ready") return hasAll;
if (filter === "missing") return hasNone;
if (filter === "partial") return !hasAll && !hasNone;
return true;
});
// Stats
const readyCount = assets.filter((e) => e.bootloader?.exists && e.partitions?.exists).length;
const missingCount = assets.filter((e) => !e.bootloader?.exists && !e.partitions?.exists).length;
const partialCount = assets.length - readyCount - missingCount;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.7)", padding: "0 1rem" }}
onClick={onClose}
>
<div
className="rounded-lg border w-full flex flex-col"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
maxWidth: "960px",
height: "70vh",
}}
onClick={(e) => e.stopPropagation()}
>
{/* ── Header ── */}
<div
className="flex items-start justify-between px-6 pt-5 pb-4"
style={{ borderBottom: "1px solid var(--border-secondary)", flexShrink: 0 }}
>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
Flash Asset Manager
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Bootloader and partition table binaries per board type PlatformIO build artifacts flashed at 0x1000 and 0x8000.
</p>
{/* Stats */}
{!loading && (
<div className="flex gap-3 mt-2">
{[
{ label: "Ready", count: readyCount, color: "var(--success-text)", bg: "var(--success-bg)", key: "ready" },
{ label: "Partial", count: partialCount, color: "#f59e0b", bg: "#2a1a00", key: "partial" },
{ label: "Missing", count: missingCount, color: "var(--danger-text)", bg: "var(--danger-bg)", key: "missing" },
].map(({ label, count, color, bg, key }) => count > 0 && (
<button
key={key}
onClick={() => setFilter(filter === key ? "all" : key)}
className="text-xs px-2 py-0.5 rounded-full font-medium cursor-pointer hover:opacity-80 transition-opacity"
style={{
backgroundColor: bg, color,
outline: filter === key ? `2px solid ${color}` : "none",
outlineOffset: "1px",
}}
>
{count} {label}
</button>
))}
{filter !== "all" && (
<button
onClick={() => setFilter("all")}
className="text-xs px-2 py-0.5 cursor-pointer hover:opacity-80 transition-opacity"
style={{ color: "var(--text-muted)" }}
>
Show all
</button>
)}
</div>
)}
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", flexShrink: 0 }}>
<button
onClick={fetchAssets}
disabled={loading}
title="Refresh"
className="flex items-center gap-1.5 px-3 py-2 text-sm rounded-md cursor-pointer hover:opacity-80 transition-opacity disabled:opacity-50"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
<IconRefresh />
Refresh
</button>
<button
onClick={onClose}
className="text-lg leading-none hover:opacity-70 cursor-pointer"
style={{ color: "var(--text-muted)", marginLeft: "0.25rem" }}
>
</button>
</div>
</div>
{/* ── Body ── */}
<div className="flex-1 overflow-y-auto p-6">
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="flex items-center justify-center py-16" style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
Loading
</div>
) : filteredAssets.length === 0 ? (
<div className="flex items-center justify-center py-16" style={{ color: "var(--text-muted)", fontSize: "0.875rem" }}>
No assets match the current filter.
</div>
) : (
<>
{/* Standard boards section */}
{(() => {
const standard = filteredAssets.filter((e) => !e.is_bespoke);
if (standard.length === 0) return null;
return (
<div style={{ marginBottom: "1.5rem" }}>
<p className="text-xs font-semibold uppercase mb-3" style={{ color: "var(--text-muted)", letterSpacing: "0.06em" }}>
Standard Boards
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{standard.map((entry) => (
<BoardCard
key={entry.hw_type}
entry={entry}
canDelete={canDelete}
canEdit={canEdit}
onUpload={(hwType, asset) => setUploadTarget({ hwType, assetName: asset })}
onDelete={(hwType, asset) => setDeleteTarget({ hwType, assetName: asset })}
onNote={(e) => setNoteTarget(e)}
onRefresh={fetchAssets}
/>
))}
</div>
</div>
);
})()}
{/* Bespoke boards section */}
{(() => {
const bespoke = filteredAssets.filter((e) => e.is_bespoke);
if (bespoke.length === 0) return null;
return (
<div>
<p className="text-xs font-semibold uppercase mb-3" style={{ color: "var(--text-muted)", letterSpacing: "0.06em" }}>
Bespoke Devices
</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
{bespoke.map((entry) => (
<BoardCard
key={entry.hw_type}
entry={entry}
canDelete={canDelete}
canEdit={canEdit}
onUpload={(hwType, asset) => setUploadTarget({ hwType, assetName: asset })}
onDelete={(hwType, asset) => setDeleteTarget({ hwType, assetName: asset })}
onNote={(e) => setNoteTarget(e)}
onRefresh={fetchAssets}
/>
))}
</div>
</div>
);
})()}
</>
)}
</div>
{/* ── Footer ── */}
<div
className="flex items-center justify-between px-6 py-3"
style={{ borderTop: "1px solid var(--border-secondary)", flexShrink: 0 }}
>
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
{assets.length} board type{assets.length !== 1 ? "s" : ""} tracked
{filteredAssets.length !== assets.length ? ` · ${filteredAssets.length} shown` : ""}
</p>
<button
onClick={onClose}
className="px-4 py-2 text-sm rounded-md hover:opacity-80 cursor-pointer"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
>
Close
</button>
</div>
</div>
{/* ── Modals ── */}
{uploadTarget && (
<UploadModal
hwType={uploadTarget.hwType}
assetName={uploadTarget.assetName}
onClose={() => setUploadTarget(null)}
onSaved={() => { setUploadTarget(null); fetchAssets(); }}
/>
)}
{deleteTarget && (
<DeleteConfirmModal
hwType={deleteTarget.hwType}
assetName={deleteTarget.assetName}
onClose={() => setDeleteTarget(null)}
onConfirmed={() => { setDeleteTarget(null); fetchAssets(); }}
/>
)}
{noteTarget && (
<NoteModal
hwType={noteTarget.hw_type}
currentNote={noteTarget.note}
onClose={() => setNoteTarget(null)}
onSaved={(newNote) => {
// Optimistically update local state
setAssets((prev) => prev.map((e) => e.hw_type === noteTarget.hw_type ? { ...e, note: newNote } : e));
setNoteTarget(null);
}}
/>
)}
</div>
);
}

View File

@@ -63,4 +63,5 @@ http {
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
} }
} }
} }