update: added assets manager and extra nvs settings on cloudflash
This commit is contained in:
@@ -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,
|
||||||
}
|
}
|
||||||
history.append(new_entry)
|
|
||||||
|
# Overwrite existing entry for this status if present, else append
|
||||||
|
existing_idx = next(
|
||||||
|
(i for i, e in enumerate(history) if e.get("status_id") == body.status_id),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing_idx is not None:
|
||||||
|
history[existing_idx] = new_entry
|
||||||
|
else:
|
||||||
|
history.append(new_entry)
|
||||||
|
|
||||||
doc_ref.update({"lifecycle_history": history})
|
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,
|
||||||
|
|||||||
@@ -168,16 +168,23 @@ 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,
|
||||||
}
|
}
|
||||||
history.append(new_entry)
|
existing_idx = next(
|
||||||
|
(i for i, e in enumerate(history) if e.get("status_id") == data.status.value),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if existing_idx is not None:
|
||||||
|
history[existing_idx] = new_entry
|
||||||
|
else:
|
||||||
|
history.append(new_entry)
|
||||||
|
|
||||||
update = {
|
update = {
|
||||||
"mfg_status": data.status.value,
|
"mfg_status": data.status.value,
|
||||||
@@ -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)
|
||||||
|
|||||||
BIN
backend/utils/emails/assets/bell_systems_horizontal_darkMode.png
Normal file
BIN
backend/utils/emails/assets/bell_systems_horizontal_darkMode.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
220
backend/utils/emails/device_assigned_mail.py
Normal file
220
backend/utils/emails/device_assigned_mail.py
Normal 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 </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 </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 </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,
|
||||||
|
)
|
||||||
155
backend/utils/emails/device_mfged_mail.py
Normal file
155
backend/utils/emails/device_mfged_mail.py
Normal 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,
|
||||||
|
)
|
||||||
268
frontend/package-lock.json
generated
268
frontend/package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,59 +430,98 @@ 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)" }}
|
||||||
>
|
>
|
||||||
<label className="block text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
|
{/* Two-column split: 70% serial input / 30% NVS schema selector */}
|
||||||
Device Serial Number
|
<div className="flex" style={{ minHeight: 0 }}>
|
||||||
</label>
|
|
||||||
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
{/* Left — serial input (70%) */}
|
||||||
Your serial number is printed on the sticker on the bottom of your device.<br></br>
|
{/* Left — NVS schema selector (30%) */}
|
||||||
It looks something like: <span className="font-mono" style={{ color: "var(--text-secondary)" }}>BSVSPR-28F17R-PRO10R-4UAQPF</span>.
|
<div className="p-4" style={{ flex: "0 0 35%" }}>
|
||||||
<br></br>Enter it exactly as shown, then click outside the box to verify.
|
<label className="block text-sm font-semibold mb-1" style={{ color: "var(--text-heading)" }}>
|
||||||
</p>
|
NVS Identity Format
|
||||||
<div className="relative">
|
</label>
|
||||||
<input
|
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
||||||
type="text"
|
Choose the format that matches your device generation.
|
||||||
value={serial}
|
</p>
|
||||||
onChange={(e) => { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }}
|
<div className="flex flex-col gap-2">
|
||||||
onBlur={handleSerialBlur}
|
<NvsSchemaButton
|
||||||
placeholder="BSXXXX-XXXXXX-XXXXXX-XXXXXX"
|
label="Current Generation"
|
||||||
className="w-full px-3 py-2.5 rounded-md text-sm border font-mono pr-10"
|
description="serial · hwFamily · hwRevision"
|
||||||
style={{ backgroundColor: "var(--bg-input)", borderColor: inputBorderColor, color: "var(--text-primary)" }}
|
selected={nvsSchema === NVS_SCHEMA_NEW}
|
||||||
spellCheck={false}
|
isDefault
|
||||||
disabled={validating}
|
onClick={() => setNvsSchema(NVS_SCHEMA_NEW)}
|
||||||
/>
|
/>
|
||||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
<NvsSchemaButton
|
||||||
{validating && (
|
label="Legacy Generation"
|
||||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24" style={{ color: "var(--text-muted)" }}>
|
description="deviceUID · hwType · hwVersion"
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
selected={nvsSchema === NVS_SCHEMA_LEGACY}
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
onClick={() => setNvsSchema(NVS_SCHEMA_LEGACY)}
|
||||||
</svg>
|
/>
|
||||||
|
</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)" }}>
|
||||||
|
Device Serial Number
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Found on the sticker on the bottom of your device.{" "}
|
||||||
|
{nvsSchema === NVS_SCHEMA_NEW
|
||||||
|
? <>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>
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={serial}
|
||||||
|
onChange={(e) => { setSerial(e.target.value.toUpperCase()); setSerialError(""); setSerialValid(false); }}
|
||||||
|
onBlur={handleSerialBlur}
|
||||||
|
placeholder={nvsSchema === NVS_SCHEMA_NEW ? "BSXXXX-XXXXXX-XXXXXX-XXXXXX" : "PVXXXXXXXXXXXX"}
|
||||||
|
className="w-full px-3 py-2.5 rounded-md text-sm border font-mono pr-10"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: inputBorderColor, color: "var(--text-primary)" }}
|
||||||
|
spellCheck={false}
|
||||||
|
disabled={validating}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||||
|
{validating && (
|
||||||
|
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24" style={{ color: "var(--text-muted)" }}>
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!validating && serialValid && (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
{!validating && serialError && (
|
||||||
|
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--danger)" }}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{serialError && (
|
||||||
|
<p className="text-xs mt-2 leading-relaxed" style={{ color: "var(--danger)" }}>{serialError}</p>
|
||||||
)}
|
)}
|
||||||
{!validating && serialValid && (
|
{serialValid && (
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--accent)" }}>
|
<p className="text-xs mt-2" style={{ color: "var(--accent)" }}>
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
|
✓ Serial verified — device found in the BellSystems database.
|
||||||
</svg>
|
</p>
|
||||||
)}
|
|
||||||
{!validating && serialError && (
|
|
||||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" style={{ color: "var(--danger)" }}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
||||||
</svg>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
{serialError && (
|
|
||||||
<p className="text-xs mt-2 leading-relaxed" style={{ color: "var(--danger)" }}>{serialError}</p>
|
|
||||||
)}
|
|
||||||
{serialValid && (
|
|
||||||
<p className="text-xs mt-2" style={{ color: "var(--accent)" }}>
|
|
||||||
✓ Serial number verified — device found in the BellSystems database.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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/{env}/</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)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|||||||
@@ -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,118 +59,301 @@ function Field({ label, value, mono = false }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── User Search Modal (for Claimed) ──────────────────────────────────────────
|
// ─── Shared 3-step assignment modal ───────────────────────────────────────────
|
||||||
|
// mode: "claimed" (user search) | "sold" (customer search, pre-assigned)
|
||||||
|
|
||||||
function UserSearchModal({ onSelect, onCancel, existingUsers = [] }) {
|
function AssignmentFlowModal({ mode, device, assignedCustomer, existingUsers = [], onComplete, onCancel }) {
|
||||||
|
// mode="sold": customer is already assigned (or being assigned), we just confirm + email
|
||||||
|
// mode="claimed": pick a user first
|
||||||
|
const isClaimed = mode === "claimed";
|
||||||
|
const accentColor = isClaimed ? "var(--lc-claimed-accent)" : "var(--lc-sold-accent)";
|
||||||
|
const accentBg = isClaimed ? "var(--lc-claimed-bg)" : "var(--lc-sold-bg)";
|
||||||
|
const icon = isClaimed ? "✅" : "📦";
|
||||||
|
const modeLabel = isClaimed ? "Set as Claimed" : "Set as Sold";
|
||||||
|
|
||||||
|
// Step 1: search & select
|
||||||
|
const [step, setStep] = useState(1);
|
||||||
|
const [selectedUser, setSelectedUser] = useState(null); // for claimed
|
||||||
|
const [keepExisting, setKeepExisting] = useState(false);
|
||||||
|
|
||||||
|
// Step 2: email
|
||||||
|
const [sendEmail, setSendEmail] = useState(false);
|
||||||
|
const [emailAddress, setEmailAddress] = useState("");
|
||||||
|
|
||||||
|
// Step 3: done
|
||||||
|
const [completing, setCompleting] = useState(false);
|
||||||
|
const [done, setDone] = useState(false);
|
||||||
|
|
||||||
|
// Search state (claimed only)
|
||||||
const [query, setQuery] = useState("");
|
const [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>
|
<div className="rounded-xl border shadow-2xl w-full max-w-md" style={{ backgroundColor: "var(--bg-card)", borderColor: accentColor + "60" }}>
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: "#fb923c" }}>Set as Claimed</p>
|
|
||||||
<h2 className="text-sm font-bold" style={{ color: "var(--text-heading)" }}>Assign a User</h2>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{/* Keep existing users option */}
|
{/* Header */}
|
||||||
{existingUsers.length > 0 && (
|
<div className="flex items-center gap-3 px-5 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
|
||||||
<div className="mb-3">
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
|
||||||
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Already assigned</p>
|
style={{ backgroundColor: accentBg, border: `1px solid ${accentColor}40` }}>
|
||||||
{existingUsers.map((u) => (
|
<span>{icon}</span>
|
||||||
<button
|
</div>
|
||||||
key={u.uid}
|
<div className="flex-1">
|
||||||
type="button"
|
<p className="text-xs font-semibold uppercase tracking-wider" style={{ color: accentColor }}>{modeLabel}</p>
|
||||||
onClick={() => onSelect(u, true)}
|
<h2 className="text-sm font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
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"
|
{done ? "Done!" : step === 1 ? (isClaimed ? "Select User" : "Select Customer") : step === 2 ? "Send Email?" : "Complete"}
|
||||||
style={{ backgroundColor: "#431407", borderColor: "#f9731640", color: "var(--text-primary)" }}
|
</h2>
|
||||||
>
|
</div>
|
||||||
<div className="w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
{/* Step indicator */}
|
||||||
style={{ backgroundColor: "#f9731630", color: "#fb923c" }}>
|
{!done && (
|
||||||
{(u.display_name || u.email || "U")[0].toUpperCase()}
|
<div className="flex items-center gap-1.5 flex-shrink-0">
|
||||||
</div>
|
{(isClaimed ? [1, 2, 3] : [2, 3]).map((s) => (
|
||||||
<div className="min-w-0 flex-1">
|
<div key={s} style={{
|
||||||
<span className="font-medium block truncate">{u.display_name || u.email || u.uid}</span>
|
width: 6, height: 6, borderRadius: "50%",
|
||||||
{u.email && u.display_name && <span className="text-xs block" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
backgroundColor: step === s ? accentColor : step > s ? accentColor + "60" : "var(--border-secondary)",
|
||||||
</div>
|
transition: "background 0.2s",
|
||||||
<span className="text-xs flex-shrink-0 px-2 py-0.5 rounded"
|
}} />
|
||||||
style={{ backgroundColor: "#f9731620", color: "#fb923c", border: "1px solid #f9731640" }}>
|
))}
|
||||||
Keep
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<div className="flex items-center gap-2 my-3">
|
|
||||||
<div className="flex-1 h-px" style={{ backgroundColor: "var(--border-secondary)" }} />
|
|
||||||
<span className="text-xs" style={{ color: "var(--text-muted)" }}>or assign a different user</span>
|
|
||||||
<div className="flex-1 h-px" style={{ backgroundColor: "var(--border-secondary)" }} />
|
|
||||||
</div>
|
</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)" }}>
|
||||||
|
Search and select the user to assign to this device.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{existingUsers.length > 0 && (
|
||||||
|
<div className="mb-3">
|
||||||
|
<p className="text-xs font-medium mb-1.5" style={{ color: "var(--text-muted)" }}>Already assigned</p>
|
||||||
|
{existingUsers.map((u) => (
|
||||||
|
<button key={u.uid} type="button" onClick={() => advanceToEmailStep(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"
|
||||||
|
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"
|
||||||
|
style={{ backgroundColor: accentColor + "30", color: accentColor }}>
|
||||||
|
{(u.display_name || u.email || "U")[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<span className="font-medium block truncate">{u.display_name || u.email || u.uid}</span>
|
||||||
|
{u.email && u.display_name && <span className="text-xs block" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs flex-shrink-0 px-2 py-0.5 rounded"
|
||||||
|
style={{ backgroundColor: accentColor + "20", color: accentColor, border: `1px solid ${accentColor}40` }}>
|
||||||
|
Keep & Proceed
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center gap-2 my-3">
|
||||||
|
<div className="flex-1 h-px" style={{ backgroundColor: "var(--border-secondary)" }} />
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>or assign a different user</span>
|
||||||
|
<div className="flex-1 h-px" style={{ backgroundColor: "var(--border-secondary)" }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ position: "relative" }} className="mb-2">
|
||||||
|
<input ref={inputRef} type="text" value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by name or email…"
|
||||||
|
className="w-full px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
|
/>
|
||||||
|
{searching && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}>…</span>}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border overflow-y-auto mb-4" style={{ borderColor: "var(--border-secondary)", maxHeight: 200, minHeight: 44 }}>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{searching ? "Searching…" : query ? "No users found." : "Type to search users…"}
|
||||||
|
</p>
|
||||||
|
) : results.map((u) => (
|
||||||
|
<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"
|
||||||
|
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>
|
||||||
|
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<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>
|
||||||
|
</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 style={{ position: "relative" }} className="mb-3">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="Search by name or email…"
|
|
||||||
className="w-full px-3 py-2 rounded-md text-sm border"
|
|
||||||
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
|
||||||
/>
|
|
||||||
{searching && <span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs" style={{ color: "var(--text-muted)" }}>…</span>}
|
|
||||||
</div>
|
|
||||||
<div className="rounded-md border overflow-y-auto" style={{ borderColor: "var(--border-secondary)", maxHeight: 220, minHeight: 48 }}>
|
|
||||||
{results.length === 0 ? (
|
|
||||||
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{searching ? "Searching…" : query ? "No users found." : "Type to search users…"}
|
|
||||||
</p>
|
|
||||||
) : results.map((u) => (
|
|
||||||
<button key={u.id || u.uid} type="button" onClick={() => onSelect(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"
|
|
||||||
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>
|
|
||||||
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 justify-end mt-4">
|
|
||||||
<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>
|
|
||||||
</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("");
|
||||||
try {
|
if (assignFlowMode === "claimed") {
|
||||||
// 1. Add user to device's user_list (skip if keeping the existing user)
|
setUserSaving(true);
|
||||||
if (!keepExisting) {
|
try {
|
||||||
await api.request(`/devices/${device.id}/users`, {
|
if (!keepExisting) {
|
||||||
method: "POST",
|
await api.request(`/devices/${device.id}/user-list`, {
|
||||||
body: JSON.stringify({ user_id: user.id || user.uid }),
|
method: "POST",
|
||||||
|
body: JSON.stringify({ user_id: user.id || user.uid }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ status: "claimed", force_claimed: true }),
|
||||||
});
|
});
|
||||||
|
setDevice(updated);
|
||||||
|
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) {
|
||||||
|
setStatusError(err.message);
|
||||||
|
throw err; // so modal stays open on error
|
||||||
|
} finally {
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
// 2. Set status to claimed (backend guard now passes since user_list is non-empty)
|
|
||||||
const updated = await api.request(`/manufacturing/devices/${sn}/status`, {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify({ status: "claimed", force_claimed: true }),
|
|
||||||
});
|
|
||||||
setDevice(updated);
|
|
||||||
if (updated.user_list?.length) fetchResolvedUsers(updated.user_list);
|
|
||||||
} catch (err) {
|
|
||||||
setStatusError(err.message);
|
|
||||||
} finally {
|
|
||||||
setUserSaving(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── 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}
|
||||||
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
onClick={(e) => { if (!e.target.closest("[data-reassign-btn]")) navigate(`/crm/customers/${device.customer_id}`); }}
|
||||||
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
onKeyDown={(e) => { if ((e.key === "Enter" || e.key === " ") && !e.target.closest("[data-reassign-btn]")) navigate(`/crm/customers/${device.customer_id}`); }}
|
||||||
{(assignedCustomer?.name || "?")[0].toUpperCase()}
|
className="flex items-center gap-3 px-3 py-2 rounded-md border transition-colors"
|
||||||
</div>
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)", cursor: "pointer" }}
|
||||||
<div className="min-w-0">
|
onMouseEnter={(e) => e.currentTarget.style.borderColor = "var(--border-primary)"}
|
||||||
<p className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
onMouseLeave={(e) => e.currentTarget.style.borderColor = "var(--border-secondary)"}
|
||||||
{[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(" ") || "Customer"}
|
>
|
||||||
</p>
|
<div className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold flex-shrink-0"
|
||||||
{assignedCustomer?.organization && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.organization}</p>}
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||||
{assignedCustomer?.city && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{assignedCustomer.city}</p>}
|
{(assignedCustomer?.name || "?")[0].toUpperCase()}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={() => setShowCustomerModal(true)} disabled={assignSaving}
|
<div className="min-w-0 flex-1">
|
||||||
className="px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-80 disabled:opacity-50"
|
<p className="text-sm font-semibold" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{[assignedCustomer?.name, assignedCustomer?.surname].filter(Boolean).join(" ") || "Customer"}
|
||||||
|
</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>}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
data-reassign-btn
|
||||||
|
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 && (
|
||||||
|
|||||||
793
frontend/src/manufacturing/FlashAssetManager.jsx
Normal file
793
frontend/src/manufacturing/FlashAssetManager.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -63,4 +63,5 @@ http {
|
|||||||
proxy_set_header Connection "upgrade";
|
proxy_set_header Connection "upgrade";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user