update: overhauled firmware ui. Added public flash page.
This commit is contained in:
@@ -5,23 +5,34 @@ from database import get_db
|
|||||||
logger = logging.getLogger("builder.database")
|
logger = logging.getLogger("builder.database")
|
||||||
|
|
||||||
|
|
||||||
async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None:
|
async def insert_built_melody(melody_id: str, name: str, pid: str, steps: str, is_builtin: bool = False) -> None:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids)
|
"""INSERT INTO built_melodies (id, name, pid, steps, assigned_melody_ids, is_builtin)
|
||||||
VALUES (?, ?, ?, ?, ?)""",
|
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||||
(melody_id, name, pid, steps, json.dumps([])),
|
(melody_id, name, pid, steps, json.dumps([]), 1 if is_builtin else 0),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def update_built_melody(melody_id: str, name: str, pid: str, steps: str) -> None:
|
async def update_built_melody(melody_id: str, name: str, pid: str, steps: str, is_builtin: bool = False) -> None:
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
await db.execute(
|
await db.execute(
|
||||||
"""UPDATE built_melodies
|
"""UPDATE built_melodies
|
||||||
SET name = ?, pid = ?, steps = ?, updated_at = datetime('now')
|
SET name = ?, pid = ?, steps = ?, is_builtin = ?, updated_at = datetime('now')
|
||||||
WHERE id = ?""",
|
WHERE id = ?""",
|
||||||
(name, pid, steps, melody_id),
|
(name, pid, steps, 1 if is_builtin else 0, melody_id),
|
||||||
|
)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def update_builtin_flag(melody_id: str, is_builtin: bool) -> None:
|
||||||
|
db = await get_db()
|
||||||
|
await db.execute(
|
||||||
|
"""UPDATE built_melodies
|
||||||
|
SET is_builtin = ?, updated_at = datetime('now')
|
||||||
|
WHERE id = ?""",
|
||||||
|
(1 if is_builtin else 0, melody_id),
|
||||||
)
|
)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
@@ -68,6 +79,7 @@ async def get_built_melody(melody_id: str) -> dict | None:
|
|||||||
return None
|
return None
|
||||||
row = dict(rows[0])
|
row = dict(rows[0])
|
||||||
row["assigned_melody_ids"] = json.loads(row["assigned_melody_ids"] or "[]")
|
row["assigned_melody_ids"] = json.loads(row["assigned_melody_ids"] or "[]")
|
||||||
|
row["is_builtin"] = bool(row.get("is_builtin", 0))
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
|
||||||
@@ -80,6 +92,7 @@ async def list_built_melodies() -> list[dict]:
|
|||||||
for row in rows:
|
for row in rows:
|
||||||
r = dict(row)
|
r = dict(row)
|
||||||
r["assigned_melody_ids"] = json.loads(r["assigned_melody_ids"] or "[]")
|
r["assigned_melody_ids"] = json.loads(r["assigned_melody_ids"] or "[]")
|
||||||
|
r["is_builtin"] = bool(r.get("is_builtin", 0))
|
||||||
results.append(r)
|
results.append(r)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ class BuiltMelodyCreate(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
pid: str
|
pid: str
|
||||||
steps: str # raw step string e.g. "1,2,2+1,1,2,3+1"
|
steps: str # raw step string e.g. "1,2,2+1,1,2,3+1"
|
||||||
|
is_builtin: bool = False
|
||||||
|
|
||||||
|
|
||||||
class BuiltMelodyUpdate(BaseModel):
|
class BuiltMelodyUpdate(BaseModel):
|
||||||
name: Optional[str] = None
|
name: Optional[str] = None
|
||||||
pid: Optional[str] = None
|
pid: Optional[str] = None
|
||||||
steps: Optional[str] = None
|
steps: Optional[str] = None
|
||||||
|
is_builtin: Optional[bool] = None
|
||||||
|
|
||||||
|
|
||||||
class BuiltMelodyInDB(BaseModel):
|
class BuiltMelodyInDB(BaseModel):
|
||||||
@@ -19,6 +21,7 @@ class BuiltMelodyInDB(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
pid: str
|
pid: str
|
||||||
steps: str
|
steps: str
|
||||||
|
is_builtin: bool = False
|
||||||
binary_path: Optional[str] = None
|
binary_path: Optional[str] = None
|
||||||
binary_url: Optional[str] = None
|
binary_url: Optional[str] = None
|
||||||
progmem_code: Optional[str] = None
|
progmem_code: Optional[str] = None
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, PlainTextResponse
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from builder.models import (
|
from builder.models import (
|
||||||
@@ -20,6 +20,7 @@ async def list_built_melodies(
|
|||||||
melodies = await service.list_built_melodies()
|
melodies = await service.list_built_melodies()
|
||||||
return BuiltMelodyListResponse(melodies=melodies, total=len(melodies))
|
return BuiltMelodyListResponse(melodies=melodies, total=len(melodies))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/for-melody/{firestore_melody_id}")
|
@router.get("/for-melody/{firestore_melody_id}")
|
||||||
async def get_for_firestore_melody(
|
async def get_for_firestore_melody(
|
||||||
firestore_melody_id: str,
|
firestore_melody_id: str,
|
||||||
@@ -32,6 +33,14 @@ async def get_for_firestore_melody(
|
|||||||
return result.model_dump()
|
return result.model_dump()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/generate-builtin-list")
|
||||||
|
async def generate_builtin_list(
|
||||||
|
_user: TokenPayload = Depends(require_permission("melodies", "view")),
|
||||||
|
):
|
||||||
|
"""Generate a C++ header with PROGMEM arrays for all is_builtin archetypes."""
|
||||||
|
code = await service.generate_builtin_list()
|
||||||
|
return PlainTextResponse(content=code, media_type="text/plain")
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{melody_id}", response_model=BuiltMelodyInDB)
|
@router.get("/{melody_id}", response_model=BuiltMelodyInDB)
|
||||||
async def get_built_melody(
|
async def get_built_melody(
|
||||||
@@ -66,6 +75,15 @@ async def delete_built_melody(
|
|||||||
await service.delete_built_melody(melody_id)
|
await service.delete_built_melody(melody_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{melody_id}/toggle-builtin", response_model=BuiltMelodyInDB)
|
||||||
|
async def toggle_builtin(
|
||||||
|
melody_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
|
):
|
||||||
|
"""Toggle the is_builtin flag for an archetype."""
|
||||||
|
return await service.toggle_builtin(melody_id)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{melody_id}/build-binary", response_model=BuiltMelodyInDB)
|
@router.post("/{melody_id}/build-binary", response_model=BuiltMelodyInDB)
|
||||||
async def build_binary(
|
async def build_binary(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ def _row_to_built_melody(row: dict) -> BuiltMelodyInDB:
|
|||||||
name=row["name"],
|
name=row["name"],
|
||||||
pid=row["pid"],
|
pid=row["pid"],
|
||||||
steps=row["steps"],
|
steps=row["steps"],
|
||||||
|
is_builtin=row.get("is_builtin", False),
|
||||||
binary_path=binary_path,
|
binary_path=binary_path,
|
||||||
binary_url=binary_url,
|
binary_url=binary_url,
|
||||||
progmem_code=row.get("progmem_code"),
|
progmem_code=row.get("progmem_code"),
|
||||||
@@ -151,8 +152,12 @@ async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB:
|
|||||||
name=data.name,
|
name=data.name,
|
||||||
pid=data.pid,
|
pid=data.pid,
|
||||||
steps=data.steps,
|
steps=data.steps,
|
||||||
|
is_builtin=data.is_builtin,
|
||||||
)
|
)
|
||||||
return await get_built_melody(melody_id)
|
# Auto-build binary and builtin code on creation
|
||||||
|
result = await get_built_melody(melody_id)
|
||||||
|
result = await _do_build(melody_id)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltMelodyInDB:
|
async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltMelodyInDB:
|
||||||
@@ -163,11 +168,22 @@ async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltM
|
|||||||
new_name = data.name if data.name is not None else row["name"]
|
new_name = data.name if data.name is not None else row["name"]
|
||||||
new_pid = data.pid if data.pid is not None else row["pid"]
|
new_pid = data.pid if data.pid is not None else row["pid"]
|
||||||
new_steps = data.steps if data.steps is not None else row["steps"]
|
new_steps = data.steps if data.steps is not None else row["steps"]
|
||||||
|
new_is_builtin = data.is_builtin if data.is_builtin is not None else row.get("is_builtin", False)
|
||||||
|
|
||||||
await _check_unique(new_name, new_pid or "", exclude_id=melody_id)
|
await _check_unique(new_name, new_pid or "", exclude_id=melody_id)
|
||||||
|
|
||||||
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps)
|
steps_changed = (data.steps is not None) and (data.steps != row["steps"])
|
||||||
return await get_built_melody(melody_id)
|
|
||||||
|
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps, is_builtin=new_is_builtin)
|
||||||
|
|
||||||
|
# If steps changed, flag all assigned melodies as outdated, then rebuild
|
||||||
|
if steps_changed:
|
||||||
|
assigned_ids = row.get("assigned_melody_ids", [])
|
||||||
|
if assigned_ids:
|
||||||
|
await _flag_melodies_outdated(assigned_ids, True)
|
||||||
|
|
||||||
|
# Auto-rebuild binary and builtin code on every save
|
||||||
|
return await _do_build(melody_id)
|
||||||
|
|
||||||
|
|
||||||
async def delete_built_melody(melody_id: str) -> None:
|
async def delete_built_melody(melody_id: str) -> None:
|
||||||
@@ -175,6 +191,11 @@ async def delete_built_melody(melody_id: str) -> None:
|
|||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
|
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
|
||||||
|
|
||||||
|
# Flag all assigned melodies as outdated before deleting
|
||||||
|
assigned_ids = row.get("assigned_melody_ids", [])
|
||||||
|
if assigned_ids:
|
||||||
|
await _flag_melodies_outdated(assigned_ids, True)
|
||||||
|
|
||||||
# Delete the .bsm file if it exists
|
# Delete the .bsm file if it exists
|
||||||
if row.get("binary_path"):
|
if row.get("binary_path"):
|
||||||
bsm_path = Path(row["binary_path"])
|
bsm_path = Path(row["binary_path"])
|
||||||
@@ -184,10 +205,26 @@ async def delete_built_melody(melody_id: str) -> None:
|
|||||||
await db.delete_built_melody(melody_id)
|
await db.delete_built_melody(melody_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def toggle_builtin(melody_id: str) -> BuiltMelodyInDB:
|
||||||
|
"""Toggle the is_builtin flag for an archetype."""
|
||||||
|
row = await db.get_built_melody(melody_id)
|
||||||
|
if not row:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Built melody '{melody_id}' not found")
|
||||||
|
new_value = not row.get("is_builtin", False)
|
||||||
|
await db.update_builtin_flag(melody_id, new_value)
|
||||||
|
return await get_built_melody(melody_id)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Build Actions
|
# Build Actions
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|
||||||
|
async def _do_build(melody_id: str) -> BuiltMelodyInDB:
|
||||||
|
"""Internal: build both binary and PROGMEM code, return updated record."""
|
||||||
|
await build_binary(melody_id)
|
||||||
|
return await build_builtin_code(melody_id)
|
||||||
|
|
||||||
|
|
||||||
async def build_binary(melody_id: str) -> BuiltMelodyInDB:
|
async def build_binary(melody_id: str) -> BuiltMelodyInDB:
|
||||||
"""Parse steps and write a .bsm binary file to storage."""
|
"""Parse steps and write a .bsm binary file to storage."""
|
||||||
row = await db.get_built_melody(melody_id)
|
row = await db.get_built_melody(melody_id)
|
||||||
@@ -236,6 +273,48 @@ async def get_binary_path(melody_id: str) -> Optional[Path]:
|
|||||||
return path
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
async def generate_builtin_list() -> str:
|
||||||
|
"""Generate a C++ header with PROGMEM arrays for all is_builtin archetypes."""
|
||||||
|
rows = await db.list_built_melodies()
|
||||||
|
builtin_rows = [r for r in rows if r.get("is_builtin")]
|
||||||
|
|
||||||
|
if not builtin_rows:
|
||||||
|
return "// No built-in archetypes defined.\n"
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
parts = [
|
||||||
|
f"// Auto-generated Built-in Archetype List",
|
||||||
|
f"// Generated: {timestamp}",
|
||||||
|
f"// Total built-ins: {len(builtin_rows)}",
|
||||||
|
"",
|
||||||
|
"#pragma once",
|
||||||
|
"#include <avr/pgmspace.h>",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
entry_refs = []
|
||||||
|
for row in builtin_rows:
|
||||||
|
values = steps_string_to_values(row["steps"])
|
||||||
|
array_name = f"melody_builtin_{row['name'].lower().replace(' ', '_')}"
|
||||||
|
display_name = row["name"].replace("_", " ").title()
|
||||||
|
pid = row.get("pid") or f"builtin_{row['name'].lower()}"
|
||||||
|
|
||||||
|
parts.append(f"// {display_name} | PID: {pid} | Steps: {len(values)}")
|
||||||
|
parts.append(format_melody_array(row["name"].lower().replace(" ", "_"), values))
|
||||||
|
parts.append("")
|
||||||
|
entry_refs.append((display_name, pid, array_name, len(values)))
|
||||||
|
|
||||||
|
# Generate MELODY_LIBRARY array
|
||||||
|
parts.append("// --- MELODY_LIBRARY entries ---")
|
||||||
|
parts.append("// Add these to your firmware's MELODY_LIBRARY[] array:")
|
||||||
|
parts.append("// {")
|
||||||
|
for display_name, pid, array_name, step_count in entry_refs:
|
||||||
|
parts.append(f'// {{ "{display_name}", "{pid}", {array_name}, {step_count} }},')
|
||||||
|
parts.append("// };")
|
||||||
|
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# Assignment
|
# Assignment
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
@@ -251,6 +330,9 @@ async def assign_to_melody(built_id: str, firestore_melody_id: str) -> BuiltMelo
|
|||||||
assigned.append(firestore_melody_id)
|
assigned.append(firestore_melody_id)
|
||||||
await db.update_assigned_melody_ids(built_id, assigned)
|
await db.update_assigned_melody_ids(built_id, assigned)
|
||||||
|
|
||||||
|
# Clear outdated flag on the melody being assigned
|
||||||
|
await _flag_melodies_outdated([firestore_melody_id], False)
|
||||||
|
|
||||||
return await get_built_melody(built_id)
|
return await get_built_melody(built_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -262,6 +344,10 @@ async def unassign_from_melody(built_id: str, firestore_melody_id: str) -> Built
|
|||||||
|
|
||||||
assigned = [mid for mid in row.get("assigned_melody_ids", []) if mid != firestore_melody_id]
|
assigned = [mid for mid in row.get("assigned_melody_ids", []) if mid != firestore_melody_id]
|
||||||
await db.update_assigned_melody_ids(built_id, assigned)
|
await db.update_assigned_melody_ids(built_id, assigned)
|
||||||
|
|
||||||
|
# Flag the melody as outdated since it no longer has an archetype
|
||||||
|
await _flag_melodies_outdated([firestore_melody_id], True)
|
||||||
|
|
||||||
return await get_built_melody(built_id)
|
return await get_built_melody(built_id)
|
||||||
|
|
||||||
|
|
||||||
@@ -272,3 +358,48 @@ async def get_built_melody_for_firestore_id(firestore_melody_id: str) -> Optiona
|
|||||||
if firestore_melody_id in row.get("assigned_melody_ids", []):
|
if firestore_melody_id in row.get("assigned_melody_ids", []):
|
||||||
return _row_to_built_melody(row)
|
return _row_to_built_melody(row)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Outdated Flag Helpers
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def _flag_melodies_outdated(melody_ids: List[str], outdated: bool) -> None:
|
||||||
|
"""Set or clear the outdated_archetype flag on a list of Firestore melody IDs.
|
||||||
|
|
||||||
|
This updates both SQLite (melody_drafts) and Firestore (published melodies).
|
||||||
|
We import inline to avoid circular imports.
|
||||||
|
"""
|
||||||
|
if not melody_ids:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
from melodies import database as melody_db
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("Could not import melody/firebase modules — skipping outdated flag update")
|
||||||
|
return
|
||||||
|
|
||||||
|
firestore_db = get_firestore()
|
||||||
|
|
||||||
|
for melody_id in melody_ids:
|
||||||
|
try:
|
||||||
|
row = await melody_db.get_melody(melody_id)
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = row["data"]
|
||||||
|
info = dict(data.get("information", {}))
|
||||||
|
info["outdated_archetype"] = outdated
|
||||||
|
data["information"] = info
|
||||||
|
|
||||||
|
await melody_db.update_melody(melody_id, data)
|
||||||
|
|
||||||
|
# If published, also update Firestore
|
||||||
|
if row.get("status") == "published":
|
||||||
|
doc_ref = firestore_db.collection("melodies").document(melody_id)
|
||||||
|
doc_ref.update({"information.outdated_archetype": outdated})
|
||||||
|
|
||||||
|
logger.info(f"Set outdated_archetype={outdated} on melody {melody_id}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to set outdated flag on melody {melody_id}: {e}")
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ async def init_db():
|
|||||||
"ALTER TABLE crm_media ADD COLUMN thumbnail_path TEXT",
|
"ALTER TABLE crm_media ADD COLUMN thumbnail_path TEXT",
|
||||||
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
|
"ALTER TABLE crm_quotation_items ADD COLUMN description_en TEXT",
|
||||||
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
|
"ALTER TABLE crm_quotation_items ADD COLUMN description_gr TEXT",
|
||||||
|
"ALTER TABLE built_melodies ADD COLUMN is_builtin INTEGER NOT NULL DEFAULT 0",
|
||||||
]
|
]
|
||||||
for m in _migrations:
|
for m in _migrations:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -126,6 +126,12 @@ class DeviceCreate(BaseModel):
|
|||||||
websocket_url: str = ""
|
websocket_url: str = ""
|
||||||
churchAssistantURL: str = ""
|
churchAssistantURL: str = ""
|
||||||
staffNotes: str = ""
|
staffNotes: str = ""
|
||||||
|
hw_family: str = ""
|
||||||
|
hw_revision: str = ""
|
||||||
|
tags: List[str] = []
|
||||||
|
serial_number: str = ""
|
||||||
|
customer_id: str = ""
|
||||||
|
mfg_status: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DeviceUpdate(BaseModel):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -145,10 +151,16 @@ class DeviceUpdate(BaseModel):
|
|||||||
websocket_url: Optional[str] = None
|
websocket_url: Optional[str] = None
|
||||||
churchAssistantURL: Optional[str] = None
|
churchAssistantURL: Optional[str] = None
|
||||||
staffNotes: Optional[str] = None
|
staffNotes: Optional[str] = None
|
||||||
|
hw_family: Optional[str] = None
|
||||||
|
hw_revision: Optional[str] = None
|
||||||
|
tags: Optional[List[str]] = None
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
mfg_status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceInDB(DeviceCreate):
|
class DeviceInDB(DeviceCreate):
|
||||||
id: str
|
id: str
|
||||||
|
# Legacy field — kept for backwards compat; new docs use serial_number
|
||||||
device_id: str = ""
|
device_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
@@ -157,6 +169,15 @@ class DeviceListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceNoteCreate(BaseModel):
|
||||||
|
content: str
|
||||||
|
created_by: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceNoteUpdate(BaseModel):
|
||||||
|
content: str
|
||||||
|
|
||||||
|
|
||||||
class DeviceUserInfo(BaseModel):
|
class DeviceUserInfo(BaseModel):
|
||||||
"""User info resolved from device_users sub-collection or user_list."""
|
"""User info resolved from device_users sub-collection or user_list."""
|
||||||
user_id: str = ""
|
user_id: str = ""
|
||||||
|
|||||||
@@ -1,17 +1,25 @@
|
|||||||
from fastapi import APIRouter, Depends, Query
|
import uuid
|
||||||
from typing import Optional
|
from datetime import datetime
|
||||||
|
from fastapi import APIRouter, Depends, Query, HTTPException
|
||||||
|
from typing import Optional, List
|
||||||
|
from pydantic import BaseModel
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
from devices.models import (
|
from devices.models import (
|
||||||
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse,
|
||||||
DeviceUsersResponse, DeviceUserInfo,
|
DeviceUsersResponse, DeviceUserInfo,
|
||||||
|
DeviceNoteCreate, DeviceNoteUpdate,
|
||||||
)
|
)
|
||||||
from devices import service
|
from devices import service
|
||||||
import database as mqtt_db
|
import database as mqtt_db
|
||||||
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
from mqtt.models import DeviceAlertEntry, DeviceAlertsResponse
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
router = APIRouter(prefix="/api/devices", tags=["devices"])
|
||||||
|
|
||||||
|
NOTES_COLLECTION = "notes"
|
||||||
|
CRM_COLLECTION = "crm_customers"
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DeviceListResponse)
|
@router.get("", response_model=DeviceListResponse)
|
||||||
async def list_devices(
|
async def list_devices(
|
||||||
@@ -79,3 +87,375 @@ async def get_device_alerts(
|
|||||||
"""Return the current active alert set for a device. Empty list means fully healthy."""
|
"""Return the current active alert set for a device. Empty list means fully healthy."""
|
||||||
rows = await mqtt_db.get_alerts(device_id)
|
rows = await mqtt_db.get_alerts(device_id)
|
||||||
return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows])
|
return DeviceAlertsResponse(alerts=[DeviceAlertEntry(**r) for r in rows])
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Device Notes
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{device_id}/notes")
|
||||||
|
async def list_device_notes(
|
||||||
|
device_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
|
):
|
||||||
|
"""List all notes for a device."""
|
||||||
|
db = get_firestore()
|
||||||
|
docs = db.collection(NOTES_COLLECTION).where("device_id", "==", device_id).order_by("created_at").stream()
|
||||||
|
notes = []
|
||||||
|
for doc in docs:
|
||||||
|
note = doc.to_dict()
|
||||||
|
note["id"] = doc.id
|
||||||
|
# Convert Firestore Timestamps to ISO strings
|
||||||
|
for f in ("created_at", "updated_at"):
|
||||||
|
if hasattr(note.get(f), "isoformat"):
|
||||||
|
note[f] = note[f].isoformat()
|
||||||
|
notes.append(note)
|
||||||
|
return {"notes": notes, "total": len(notes)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{device_id}/notes", status_code=201)
|
||||||
|
async def create_device_note(
|
||||||
|
device_id: str,
|
||||||
|
body: DeviceNoteCreate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Create a new note for a device."""
|
||||||
|
db = get_firestore()
|
||||||
|
now = datetime.utcnow()
|
||||||
|
note_id = str(uuid.uuid4())
|
||||||
|
note_data = {
|
||||||
|
"device_id": device_id,
|
||||||
|
"content": body.content,
|
||||||
|
"created_by": body.created_by or _user.name or "",
|
||||||
|
"created_at": now,
|
||||||
|
"updated_at": now,
|
||||||
|
}
|
||||||
|
db.collection(NOTES_COLLECTION).document(note_id).set(note_data)
|
||||||
|
note_data["id"] = note_id
|
||||||
|
note_data["created_at"] = now.isoformat()
|
||||||
|
note_data["updated_at"] = now.isoformat()
|
||||||
|
return note_data
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{device_id}/notes/{note_id}")
|
||||||
|
async def update_device_note(
|
||||||
|
device_id: str,
|
||||||
|
note_id: str,
|
||||||
|
body: DeviceNoteUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Update an existing device note."""
|
||||||
|
db = get_firestore()
|
||||||
|
doc_ref = db.collection(NOTES_COLLECTION).document(note_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists or doc.to_dict().get("device_id") != device_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
now = datetime.utcnow()
|
||||||
|
doc_ref.update({"content": body.content, "updated_at": now})
|
||||||
|
updated = doc.to_dict()
|
||||||
|
updated["id"] = note_id
|
||||||
|
updated["content"] = body.content
|
||||||
|
updated["updated_at"] = now.isoformat()
|
||||||
|
if hasattr(updated.get("created_at"), "isoformat"):
|
||||||
|
updated["created_at"] = updated["created_at"].isoformat()
|
||||||
|
return updated
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{device_id}/notes/{note_id}", status_code=204)
|
||||||
|
async def delete_device_note(
|
||||||
|
device_id: str,
|
||||||
|
note_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Delete a device note."""
|
||||||
|
db = get_firestore()
|
||||||
|
doc_ref = db.collection(NOTES_COLLECTION).document(note_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists or doc.to_dict().get("device_id") != device_id:
|
||||||
|
raise HTTPException(status_code=404, detail="Note not found")
|
||||||
|
doc_ref.delete()
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Device Tags
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class TagsUpdate(BaseModel):
|
||||||
|
tags: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{device_id}/tags", response_model=DeviceInDB)
|
||||||
|
async def update_device_tags(
|
||||||
|
device_id: str,
|
||||||
|
body: TagsUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Replace the tags list for a device."""
|
||||||
|
return service.update_device(device_id, DeviceUpdate(tags=body.tags))
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Assign Device to Customer
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CustomerSearchResult(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
email: str
|
||||||
|
organization: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class AssignCustomerBody(BaseModel):
|
||||||
|
customer_id: str
|
||||||
|
label: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/customer-search")
|
||||||
|
async def search_customers_for_device(
|
||||||
|
device_id: str,
|
||||||
|
q: str = Query(""),
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
|
):
|
||||||
|
"""Search customers by name, email, phone, org, or tags, returning top 20 matches."""
|
||||||
|
db = get_firestore()
|
||||||
|
docs = db.collection(CRM_COLLECTION).stream()
|
||||||
|
results = []
|
||||||
|
q_lower = q.lower().strip()
|
||||||
|
for doc in docs:
|
||||||
|
data = doc.to_dict()
|
||||||
|
name = data.get("name", "") or ""
|
||||||
|
surname = data.get("surname", "") or ""
|
||||||
|
email = data.get("email", "") or ""
|
||||||
|
organization = data.get("organization", "") or ""
|
||||||
|
phone = data.get("phone", "") or ""
|
||||||
|
tags = " ".join(data.get("tags", []) or [])
|
||||||
|
location = data.get("location") or {}
|
||||||
|
city = location.get("city", "") or ""
|
||||||
|
searchable = f"{name} {surname} {email} {organization} {phone} {tags} {city}".lower()
|
||||||
|
if not q_lower or q_lower in searchable:
|
||||||
|
results.append({
|
||||||
|
"id": doc.id,
|
||||||
|
"name": name,
|
||||||
|
"surname": surname,
|
||||||
|
"email": email,
|
||||||
|
"organization": organization,
|
||||||
|
"city": city,
|
||||||
|
})
|
||||||
|
if len(results) >= 20:
|
||||||
|
break
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{device_id}/assign-customer")
|
||||||
|
async def assign_device_to_customer(
|
||||||
|
device_id: str,
|
||||||
|
body: AssignCustomerBody,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Assign a device to a customer.
|
||||||
|
|
||||||
|
- Sets owner field on the device document.
|
||||||
|
- Adds a console_device entry to the customer's owned_items list.
|
||||||
|
"""
|
||||||
|
db = get_firestore()
|
||||||
|
|
||||||
|
# Verify device exists
|
||||||
|
device = service.get_device(device_id)
|
||||||
|
|
||||||
|
# Get customer
|
||||||
|
customer_ref = db.collection(CRM_COLLECTION).document(body.customer_id)
|
||||||
|
customer_doc = customer_ref.get()
|
||||||
|
if not customer_doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
customer_data = customer_doc.to_dict()
|
||||||
|
customer_email = customer_data.get("email", "")
|
||||||
|
|
||||||
|
# Update device: owner email + customer_id
|
||||||
|
device_ref = db.collection("devices").document(device_id)
|
||||||
|
device_ref.update({"owner": customer_email, "customer_id": body.customer_id})
|
||||||
|
|
||||||
|
# Add to customer owned_items (avoid duplicates)
|
||||||
|
owned_items = customer_data.get("owned_items", []) or []
|
||||||
|
already_assigned = any(
|
||||||
|
item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id
|
||||||
|
for item in owned_items
|
||||||
|
)
|
||||||
|
if not already_assigned:
|
||||||
|
owned_items.append({
|
||||||
|
"type": "console_device",
|
||||||
|
"console_device": {
|
||||||
|
"device_id": device_id,
|
||||||
|
"label": body.label or device.device_name or device_id,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
customer_ref.update({"owned_items": owned_items})
|
||||||
|
|
||||||
|
return {"status": "assigned", "device_id": device_id, "customer_id": body.customer_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{device_id}/assign-customer", status_code=204)
|
||||||
|
async def unassign_device_from_customer(
|
||||||
|
device_id: str,
|
||||||
|
customer_id: str = Query(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Remove device assignment from a customer."""
|
||||||
|
db = get_firestore()
|
||||||
|
|
||||||
|
# Clear customer_id on device
|
||||||
|
device_ref = db.collection("devices").document(device_id)
|
||||||
|
device_ref.update({"customer_id": ""})
|
||||||
|
|
||||||
|
# Remove from customer owned_items
|
||||||
|
customer_ref = db.collection(CRM_COLLECTION).document(customer_id)
|
||||||
|
customer_doc = customer_ref.get()
|
||||||
|
if customer_doc.exists:
|
||||||
|
customer_data = customer_doc.to_dict()
|
||||||
|
owned_items = [
|
||||||
|
item for item in (customer_data.get("owned_items") or [])
|
||||||
|
if not (item.get("type") == "console_device" and item.get("console_device", {}).get("device_id") == device_id)
|
||||||
|
]
|
||||||
|
customer_ref.update({"owned_items": owned_items})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# Customer detail (for Owner display in fleet)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{device_id}/customer")
|
||||||
|
async def get_device_customer(
|
||||||
|
device_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
|
):
|
||||||
|
"""Return basic customer details for a device's assigned customer_id."""
|
||||||
|
db = get_firestore()
|
||||||
|
device_ref = db.collection("devices").document(device_id)
|
||||||
|
device_doc = device_ref.get()
|
||||||
|
if not device_doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
device_data = device_doc.to_dict() or {}
|
||||||
|
customer_id = device_data.get("customer_id")
|
||||||
|
if not customer_id:
|
||||||
|
return {"customer": None}
|
||||||
|
customer_doc = db.collection(CRM_COLLECTION).document(customer_id).get()
|
||||||
|
if not customer_doc.exists:
|
||||||
|
return {"customer": None}
|
||||||
|
cd = customer_doc.to_dict() or {}
|
||||||
|
return {
|
||||||
|
"customer": {
|
||||||
|
"id": customer_doc.id,
|
||||||
|
"name": cd.get("name") or "",
|
||||||
|
"email": cd.get("email") or "",
|
||||||
|
"organization": cd.get("organization") or "",
|
||||||
|
"phone": cd.get("phone") or "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# User list management (for Manage tab — assign/remove users from user_list)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class UserSearchResult(BaseModel):
|
||||||
|
id: str
|
||||||
|
display_name: str = ""
|
||||||
|
email: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
photo_url: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{device_id}/user-search")
|
||||||
|
async def search_users_for_device(
|
||||||
|
device_id: str,
|
||||||
|
q: str = Query(""),
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "view")),
|
||||||
|
):
|
||||||
|
"""Search the users collection by name, email, or phone."""
|
||||||
|
db = get_firestore()
|
||||||
|
docs = db.collection("users").stream()
|
||||||
|
results = []
|
||||||
|
q_lower = q.lower().strip()
|
||||||
|
for doc in docs:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
name = (data.get("display_name") or "").lower()
|
||||||
|
email = (data.get("email") or "").lower()
|
||||||
|
phone = (data.get("phone") or "").lower()
|
||||||
|
if not q_lower or q_lower in name or q_lower in email or q_lower in phone:
|
||||||
|
results.append({
|
||||||
|
"id": doc.id,
|
||||||
|
"display_name": data.get("display_name") or "",
|
||||||
|
"email": data.get("email") or "",
|
||||||
|
"phone": data.get("phone") or "",
|
||||||
|
"photo_url": data.get("photo_url") or "",
|
||||||
|
})
|
||||||
|
if len(results) >= 20:
|
||||||
|
break
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
class AddUserBody(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{device_id}/user-list", status_code=200)
|
||||||
|
async def add_user_to_device(
|
||||||
|
device_id: str,
|
||||||
|
body: AddUserBody,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Add a user reference to the device's user_list field."""
|
||||||
|
db = get_firestore()
|
||||||
|
device_ref = db.collection("devices").document(device_id)
|
||||||
|
device_doc = device_ref.get()
|
||||||
|
if not device_doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
|
||||||
|
# Verify user exists
|
||||||
|
user_doc = db.collection("users").document(body.user_id).get()
|
||||||
|
if not user_doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
data = device_doc.to_dict() or {}
|
||||||
|
user_list = data.get("user_list", []) or []
|
||||||
|
|
||||||
|
# Avoid duplicates — check both string paths and DocumentReferences
|
||||||
|
from google.cloud.firestore_v1 import DocumentReference as DocRef
|
||||||
|
existing_ids = set()
|
||||||
|
for entry in user_list:
|
||||||
|
if isinstance(entry, DocRef):
|
||||||
|
existing_ids.add(entry.id)
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
existing_ids.add(entry.split("/")[-1])
|
||||||
|
|
||||||
|
if body.user_id not in existing_ids:
|
||||||
|
user_ref = db.collection("users").document(body.user_id)
|
||||||
|
user_list.append(user_ref)
|
||||||
|
device_ref.update({"user_list": user_list})
|
||||||
|
|
||||||
|
return {"status": "added", "user_id": body.user_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{device_id}/user-list/{user_id}", status_code=200)
|
||||||
|
async def remove_user_from_device(
|
||||||
|
device_id: str,
|
||||||
|
user_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("devices", "edit")),
|
||||||
|
):
|
||||||
|
"""Remove a user reference from the device's user_list field."""
|
||||||
|
db = get_firestore()
|
||||||
|
device_ref = db.collection("devices").document(device_id)
|
||||||
|
device_doc = device_ref.get()
|
||||||
|
if not device_doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
|
||||||
|
data = device_doc.to_dict() or {}
|
||||||
|
user_list = data.get("user_list", []) or []
|
||||||
|
|
||||||
|
# Remove any entry that resolves to this user_id
|
||||||
|
new_list = [
|
||||||
|
entry for entry in user_list
|
||||||
|
if not (isinstance(entry, str) and entry.split("/")[-1] == user_id)
|
||||||
|
]
|
||||||
|
device_ref.update({"user_list": new_list})
|
||||||
|
|
||||||
|
return {"status": "removed", "user_id": user_id}
|
||||||
|
|||||||
@@ -52,10 +52,11 @@ def _generate_serial_number() -> str:
|
|||||||
def _ensure_unique_serial(db) -> str:
|
def _ensure_unique_serial(db) -> str:
|
||||||
"""Generate a serial number and verify it doesn't already exist in Firestore."""
|
"""Generate a serial number and verify it doesn't already exist in Firestore."""
|
||||||
existing_sns = set()
|
existing_sns = set()
|
||||||
for doc in db.collection(COLLECTION).select(["device_id"]).stream():
|
for doc in db.collection(COLLECTION).select(["serial_number"]).stream():
|
||||||
data = doc.to_dict()
|
data = doc.to_dict()
|
||||||
if data.get("device_id"):
|
sn = data.get("serial_number") or data.get("device_id")
|
||||||
existing_sns.add(data["device_id"])
|
if sn:
|
||||||
|
existing_sns.add(sn)
|
||||||
|
|
||||||
for _ in range(100): # safety limit
|
for _ in range(100): # safety limit
|
||||||
sn = _generate_serial_number()
|
sn = _generate_serial_number()
|
||||||
@@ -95,18 +96,40 @@ def _sanitize_dict(d: dict) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _auto_upgrade_claimed(doc_ref, data: dict) -> dict:
|
||||||
|
"""If the device has entries in user_list and isn't already claimed/decommissioned,
|
||||||
|
upgrade mfg_status to 'claimed' automatically and return the updated data dict."""
|
||||||
|
current_status = data.get("mfg_status", "")
|
||||||
|
if current_status in ("claimed", "decommissioned"):
|
||||||
|
return data
|
||||||
|
user_list = data.get("user_list", []) or []
|
||||||
|
if user_list:
|
||||||
|
doc_ref.update({"mfg_status": "claimed"})
|
||||||
|
data = dict(data)
|
||||||
|
data["mfg_status"] = "claimed"
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _doc_to_device(doc) -> DeviceInDB:
|
def _doc_to_device(doc) -> DeviceInDB:
|
||||||
"""Convert a Firestore document snapshot to a DeviceInDB model."""
|
"""Convert a Firestore document snapshot to a DeviceInDB model.
|
||||||
data = _sanitize_dict(doc.to_dict())
|
|
||||||
|
Also auto-upgrades mfg_status to 'claimed' if user_list is non-empty.
|
||||||
|
"""
|
||||||
|
raw = doc.to_dict()
|
||||||
|
raw = _auto_upgrade_claimed(doc.reference, raw)
|
||||||
|
data = _sanitize_dict(raw)
|
||||||
return DeviceInDB(id=doc.id, **data)
|
return DeviceInDB(id=doc.id, **data)
|
||||||
|
|
||||||
|
|
||||||
|
FLEET_STATUSES = {"sold", "claimed"}
|
||||||
|
|
||||||
|
|
||||||
def list_devices(
|
def list_devices(
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
online_only: bool | None = None,
|
online_only: bool | None = None,
|
||||||
subscription_tier: str | None = None,
|
subscription_tier: str | None = None,
|
||||||
) -> list[DeviceInDB]:
|
) -> list[DeviceInDB]:
|
||||||
"""List devices with optional filters."""
|
"""List fleet devices (sold + claimed only) with optional filters."""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
ref = db.collection(COLLECTION)
|
ref = db.collection(COLLECTION)
|
||||||
query = ref
|
query = ref
|
||||||
@@ -118,6 +141,14 @@ def list_devices(
|
|||||||
results = []
|
results = []
|
||||||
|
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
|
raw = doc.to_dict() or {}
|
||||||
|
|
||||||
|
# Only include sold/claimed devices in the fleet view.
|
||||||
|
# Legacy devices without mfg_status are included to avoid breaking old data.
|
||||||
|
mfg_status = raw.get("mfg_status")
|
||||||
|
if mfg_status and mfg_status not in FLEET_STATUSES:
|
||||||
|
continue
|
||||||
|
|
||||||
device = _doc_to_device(doc)
|
device = _doc_to_device(doc)
|
||||||
|
|
||||||
# Client-side filters
|
# Client-side filters
|
||||||
@@ -128,7 +159,7 @@ def list_devices(
|
|||||||
search_lower = search.lower()
|
search_lower = search.lower()
|
||||||
name_match = search_lower in (device.device_name or "").lower()
|
name_match = search_lower in (device.device_name or "").lower()
|
||||||
location_match = search_lower in (device.device_location or "").lower()
|
location_match = search_lower in (device.device_location or "").lower()
|
||||||
sn_match = search_lower in (device.device_id or "").lower()
|
sn_match = search_lower in (device.serial_number or "").lower()
|
||||||
if not (name_match or location_match or sn_match):
|
if not (name_match or location_match or sn_match):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from shared.firebase import get_db
|
|||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
from equipment.models import NoteCreate, NoteUpdate, NoteInDB
|
from equipment.models import NoteCreate, NoteUpdate, NoteInDB
|
||||||
|
|
||||||
COLLECTION = "equipment_notes"
|
COLLECTION = "notes"
|
||||||
|
|
||||||
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"}
|
VALID_CATEGORIES = {"general", "maintenance", "installation", "issue", "action_item", "other"}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class UpdateType(str, Enum):
|
|||||||
|
|
||||||
class FirmwareVersion(BaseModel):
|
class FirmwareVersion(BaseModel):
|
||||||
id: str
|
id: str
|
||||||
hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro"
|
hw_type: str # e.g. "vesper", "vesper_plus", "vesper_pro", "bespoke"
|
||||||
channel: str # "stable", "beta", "alpha", "testing"
|
channel: str # "stable", "beta", "alpha", "testing"
|
||||||
version: str # semver e.g. "1.5"
|
version: str # semver e.g. "1.5"
|
||||||
filename: str
|
filename: str
|
||||||
@@ -20,8 +20,10 @@ class FirmwareVersion(BaseModel):
|
|||||||
update_type: UpdateType = UpdateType.mandatory
|
update_type: UpdateType = UpdateType.mandatory
|
||||||
min_fw_version: Optional[str] = None # minimum fw version required to install this
|
min_fw_version: Optional[str] = None # minimum fw version required to install this
|
||||||
uploaded_at: str
|
uploaded_at: str
|
||||||
notes: Optional[str] = None
|
changelog: Optional[str] = None
|
||||||
|
release_note: Optional[str] = None
|
||||||
is_latest: bool = False
|
is_latest: bool = False
|
||||||
|
bespoke_uid: Optional[str] = None # only set when hw_type == "bespoke"
|
||||||
|
|
||||||
|
|
||||||
class FirmwareListResponse(BaseModel):
|
class FirmwareListResponse(BaseModel):
|
||||||
@@ -57,7 +59,7 @@ class FirmwareMetadataResponse(BaseModel):
|
|||||||
min_fw_version: Optional[str] = None
|
min_fw_version: Optional[str] = None
|
||||||
download_url: str
|
download_url: str
|
||||||
uploaded_at: str
|
uploaded_at: str
|
||||||
notes: Optional[str] = None
|
release_note: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
# Keep backwards-compatible alias
|
# Keep backwards-compatible alias
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, Query, UploadFile, File, Form, HTTPException
|
||||||
from fastapi.responses import FileResponse
|
from fastapi.responses import FileResponse, PlainTextResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
import logging
|
import logging
|
||||||
@@ -22,7 +22,9 @@ async def upload_firmware(
|
|||||||
version: str = Form(...),
|
version: str = Form(...),
|
||||||
update_type: UpdateType = Form(UpdateType.mandatory),
|
update_type: UpdateType = Form(UpdateType.mandatory),
|
||||||
min_fw_version: Optional[str] = Form(None),
|
min_fw_version: Optional[str] = Form(None),
|
||||||
notes: Optional[str] = Form(None),
|
changelog: Optional[str] = Form(None),
|
||||||
|
release_note: Optional[str] = Form(None),
|
||||||
|
bespoke_uid: Optional[str] = Form(None),
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
):
|
):
|
||||||
@@ -34,7 +36,9 @@ async def upload_firmware(
|
|||||||
file_bytes=file_bytes,
|
file_bytes=file_bytes,
|
||||||
update_type=update_type,
|
update_type=update_type,
|
||||||
min_fw_version=min_fw_version,
|
min_fw_version=min_fw_version,
|
||||||
notes=notes,
|
changelog=changelog,
|
||||||
|
release_note=release_note,
|
||||||
|
bespoke_uid=bespoke_uid,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -61,6 +65,18 @@ def get_latest_firmware(
|
|||||||
return service.get_latest(hw_type, channel, hw_version=hw_version, current_version=current_version)
|
return service.get_latest(hw_type, channel, hw_version=hw_version, current_version=current_version)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{hw_type}/{channel}/latest/changelog", response_class=PlainTextResponse)
|
||||||
|
def get_latest_changelog(hw_type: str, channel: str):
|
||||||
|
"""Returns the full changelog for the latest firmware. Plain text."""
|
||||||
|
return service.get_latest_changelog(hw_type, channel)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{hw_type}/{channel}/{version}/info/changelog", response_class=PlainTextResponse)
|
||||||
|
def get_version_changelog(hw_type: str, channel: str, version: str):
|
||||||
|
"""Returns the full changelog for a specific firmware version. Plain text."""
|
||||||
|
return service.get_version_changelog(hw_type, channel, version)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
|
@router.get("/{hw_type}/{channel}/{version}/info", response_model=FirmwareMetadataResponse)
|
||||||
def get_firmware_info(hw_type: str, channel: str, version: str):
|
def get_firmware_info(hw_type: str, channel: str, version: str):
|
||||||
"""Returns metadata for a specific firmware version.
|
"""Returns metadata for a specific firmware version.
|
||||||
@@ -80,6 +96,33 @@ def download_firmware(hw_type: str, channel: str, version: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{firmware_id}", response_model=FirmwareVersion)
|
||||||
|
async def edit_firmware(
|
||||||
|
firmware_id: str,
|
||||||
|
channel: Optional[str] = Form(None),
|
||||||
|
version: Optional[str] = Form(None),
|
||||||
|
update_type: Optional[UpdateType] = Form(None),
|
||||||
|
min_fw_version: Optional[str] = Form(None),
|
||||||
|
changelog: Optional[str] = Form(None),
|
||||||
|
release_note: Optional[str] = Form(None),
|
||||||
|
bespoke_uid: Optional[str] = Form(None),
|
||||||
|
file: Optional[UploadFile] = File(None),
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "add")),
|
||||||
|
):
|
||||||
|
file_bytes = await file.read() if file and file.filename else None
|
||||||
|
return service.edit_firmware(
|
||||||
|
doc_id=firmware_id,
|
||||||
|
channel=channel,
|
||||||
|
version=version,
|
||||||
|
update_type=update_type,
|
||||||
|
min_fw_version=min_fw_version,
|
||||||
|
changelog=changelog,
|
||||||
|
release_note=release_note,
|
||||||
|
bespoke_uid=bespoke_uid,
|
||||||
|
file_bytes=file_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{firmware_id}", status_code=204)
|
@router.delete("/{firmware_id}", status_code=204)
|
||||||
def delete_firmware(
|
def delete_firmware(
|
||||||
firmware_id: str,
|
firmware_id: str,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
COLLECTION = "firmware_versions"
|
COLLECTION = "firmware_versions"
|
||||||
|
|
||||||
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini"}
|
VALID_HW_TYPES = {"vesper", "vesper_plus", "vesper_pro", "chronos", "chronos_pro", "agnus", "agnus_mini", "bespoke"}
|
||||||
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
|
VALID_CHANNELS = {"stable", "beta", "alpha", "testing"}
|
||||||
|
|
||||||
|
|
||||||
@@ -43,8 +43,10 @@ def _doc_to_firmware_version(doc) -> FirmwareVersion:
|
|||||||
update_type=data.get("update_type", UpdateType.mandatory),
|
update_type=data.get("update_type", UpdateType.mandatory),
|
||||||
min_fw_version=data.get("min_fw_version"),
|
min_fw_version=data.get("min_fw_version"),
|
||||||
uploaded_at=uploaded_str,
|
uploaded_at=uploaded_str,
|
||||||
notes=data.get("notes"),
|
changelog=data.get("changelog"),
|
||||||
|
release_note=data.get("release_note"),
|
||||||
is_latest=data.get("is_latest", False),
|
is_latest=data.get("is_latest", False),
|
||||||
|
bespoke_uid=data.get("bespoke_uid"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -65,7 +67,7 @@ def _fw_to_metadata_response(fw: FirmwareVersion) -> FirmwareMetadataResponse:
|
|||||||
min_fw_version=fw.min_fw_version,
|
min_fw_version=fw.min_fw_version,
|
||||||
download_url=download_url,
|
download_url=download_url,
|
||||||
uploaded_at=fw.uploaded_at,
|
uploaded_at=fw.uploaded_at,
|
||||||
notes=fw.notes,
|
release_note=fw.release_note,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -76,24 +78,50 @@ def upload_firmware(
|
|||||||
file_bytes: bytes,
|
file_bytes: bytes,
|
||||||
update_type: UpdateType = UpdateType.mandatory,
|
update_type: UpdateType = UpdateType.mandatory,
|
||||||
min_fw_version: str | None = None,
|
min_fw_version: str | None = None,
|
||||||
notes: str | None = None,
|
changelog: str | None = None,
|
||||||
|
release_note: str | None = None,
|
||||||
|
bespoke_uid: str | None = None,
|
||||||
) -> FirmwareVersion:
|
) -> FirmwareVersion:
|
||||||
if hw_type not in VALID_HW_TYPES:
|
if hw_type not in VALID_HW_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}")
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES))}")
|
||||||
if channel not in VALID_CHANNELS:
|
if channel not in VALID_CHANNELS:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
|
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
|
||||||
|
if hw_type == "bespoke" and not bespoke_uid:
|
||||||
|
raise HTTPException(status_code=400, detail="bespoke_uid is required when hw_type is 'bespoke'")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
sha256 = hashlib.sha256(file_bytes).hexdigest()
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
# For bespoke firmware: if a firmware with the same bespoke_uid already exists,
|
||||||
|
# overwrite it (delete old doc + file, reuse same storage path keyed by uid).
|
||||||
|
if hw_type == "bespoke" and bespoke_uid:
|
||||||
|
existing_docs = list(
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", "bespoke")
|
||||||
|
.where("bespoke_uid", "==", bespoke_uid)
|
||||||
|
.stream()
|
||||||
|
)
|
||||||
|
for old_doc in existing_docs:
|
||||||
|
old_data = old_doc.to_dict() or {}
|
||||||
|
old_path = _storage_path("bespoke", old_data.get("channel", channel), old_data.get("version", version))
|
||||||
|
if old_path.exists():
|
||||||
|
old_path.unlink()
|
||||||
|
try:
|
||||||
|
old_path.parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
old_doc.reference.delete()
|
||||||
|
|
||||||
dest = _storage_path(hw_type, channel, version)
|
dest = _storage_path(hw_type, channel, version)
|
||||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
dest.write_bytes(file_bytes)
|
dest.write_bytes(file_bytes)
|
||||||
|
|
||||||
sha256 = hashlib.sha256(file_bytes).hexdigest()
|
|
||||||
now = datetime.now(timezone.utc)
|
|
||||||
doc_id = str(uuid.uuid4())
|
doc_id = str(uuid.uuid4())
|
||||||
|
|
||||||
db = get_db()
|
|
||||||
|
|
||||||
# Mark previous latest for this hw_type+channel as no longer latest
|
# Mark previous latest for this hw_type+channel as no longer latest
|
||||||
|
# (skip for bespoke — each bespoke_uid is its own independent firmware)
|
||||||
|
if hw_type != "bespoke":
|
||||||
prev_docs = (
|
prev_docs = (
|
||||||
db.collection(COLLECTION)
|
db.collection(COLLECTION)
|
||||||
.where("hw_type", "==", hw_type)
|
.where("hw_type", "==", hw_type)
|
||||||
@@ -115,8 +143,10 @@ def upload_firmware(
|
|||||||
"update_type": update_type.value,
|
"update_type": update_type.value,
|
||||||
"min_fw_version": min_fw_version,
|
"min_fw_version": min_fw_version,
|
||||||
"uploaded_at": now,
|
"uploaded_at": now,
|
||||||
"notes": notes,
|
"changelog": changelog,
|
||||||
|
"release_note": release_note,
|
||||||
"is_latest": True,
|
"is_latest": True,
|
||||||
|
"bespoke_uid": bespoke_uid,
|
||||||
})
|
})
|
||||||
|
|
||||||
return _doc_to_firmware_version(doc_ref.get())
|
return _doc_to_firmware_version(doc_ref.get())
|
||||||
@@ -142,6 +172,8 @@ def list_firmware(
|
|||||||
def get_latest(hw_type: str, channel: str, hw_version: str | None = None, current_version: str | None = None) -> FirmwareMetadataResponse:
|
def get_latest(hw_type: str, channel: str, hw_version: str | None = None, current_version: str | None = None) -> FirmwareMetadataResponse:
|
||||||
if hw_type not in VALID_HW_TYPES:
|
if hw_type not in VALID_HW_TYPES:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||||
|
if hw_type == "bespoke":
|
||||||
|
raise HTTPException(status_code=400, detail="Bespoke firmware is not served via auto-update. Use the direct download URL.")
|
||||||
if channel not in VALID_CHANNELS:
|
if channel not in VALID_CHANNELS:
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
||||||
|
|
||||||
@@ -182,6 +214,52 @@ def get_version_info(hw_type: str, channel: str, version: str) -> FirmwareMetada
|
|||||||
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
|
return _fw_to_metadata_response(_doc_to_firmware_version(docs[0]))
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_changelog(hw_type: str, channel: str) -> str:
|
||||||
|
if hw_type not in VALID_HW_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||||
|
if channel not in VALID_CHANNELS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
docs = list(
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", hw_type)
|
||||||
|
.where("channel", "==", channel)
|
||||||
|
.where("is_latest", "==", True)
|
||||||
|
.limit(1)
|
||||||
|
.stream()
|
||||||
|
)
|
||||||
|
if not docs:
|
||||||
|
raise NotFoundError("Firmware")
|
||||||
|
fw = _doc_to_firmware_version(docs[0])
|
||||||
|
if not fw.changelog:
|
||||||
|
raise NotFoundError("Changelog")
|
||||||
|
return fw.changelog
|
||||||
|
|
||||||
|
|
||||||
|
def get_version_changelog(hw_type: str, channel: str, version: str) -> str:
|
||||||
|
if hw_type not in VALID_HW_TYPES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid hw_type '{hw_type}'")
|
||||||
|
if channel not in VALID_CHANNELS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid channel '{channel}'")
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
docs = list(
|
||||||
|
db.collection(COLLECTION)
|
||||||
|
.where("hw_type", "==", hw_type)
|
||||||
|
.where("channel", "==", channel)
|
||||||
|
.where("version", "==", version)
|
||||||
|
.limit(1)
|
||||||
|
.stream()
|
||||||
|
)
|
||||||
|
if not docs:
|
||||||
|
raise NotFoundError("Firmware version")
|
||||||
|
fw = _doc_to_firmware_version(docs[0])
|
||||||
|
if not fw.changelog:
|
||||||
|
raise NotFoundError("Changelog")
|
||||||
|
return fw.changelog
|
||||||
|
|
||||||
|
|
||||||
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
def get_firmware_path(hw_type: str, channel: str, version: str) -> Path:
|
||||||
path = _storage_path(hw_type, channel, version)
|
path = _storage_path(hw_type, channel, version)
|
||||||
if not path.exists():
|
if not path.exists():
|
||||||
@@ -205,6 +283,82 @@ def record_ota_event(event_type: str, payload: dict[str, Any]) -> None:
|
|||||||
logger.warning("Failed to persist OTA event (%s): %s", event_type, exc)
|
logger.warning("Failed to persist OTA event (%s): %s", event_type, exc)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_firmware(
|
||||||
|
doc_id: str,
|
||||||
|
channel: str | None = None,
|
||||||
|
version: str | None = None,
|
||||||
|
update_type: UpdateType | None = None,
|
||||||
|
min_fw_version: str | None = None,
|
||||||
|
changelog: str | None = None,
|
||||||
|
release_note: str | None = None,
|
||||||
|
bespoke_uid: str | None = None,
|
||||||
|
file_bytes: bytes | None = None,
|
||||||
|
) -> FirmwareVersion:
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(doc_id)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise NotFoundError("Firmware")
|
||||||
|
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
hw_type = data["hw_type"]
|
||||||
|
old_channel = data.get("channel", "")
|
||||||
|
old_version = data.get("version", "")
|
||||||
|
|
||||||
|
effective_channel = channel if channel is not None else old_channel
|
||||||
|
effective_version = version if version is not None else old_version
|
||||||
|
|
||||||
|
if channel is not None and channel not in VALID_CHANNELS:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Invalid channel. Must be one of: {', '.join(sorted(VALID_CHANNELS))}")
|
||||||
|
|
||||||
|
updates: dict = {}
|
||||||
|
if channel is not None:
|
||||||
|
updates["channel"] = channel
|
||||||
|
if version is not None:
|
||||||
|
updates["version"] = version
|
||||||
|
if update_type is not None:
|
||||||
|
updates["update_type"] = update_type.value
|
||||||
|
if min_fw_version is not None:
|
||||||
|
updates["min_fw_version"] = min_fw_version if min_fw_version else None
|
||||||
|
if changelog is not None:
|
||||||
|
updates["changelog"] = changelog if changelog else None
|
||||||
|
if release_note is not None:
|
||||||
|
updates["release_note"] = release_note if release_note else None
|
||||||
|
if bespoke_uid is not None:
|
||||||
|
updates["bespoke_uid"] = bespoke_uid if bespoke_uid else None
|
||||||
|
|
||||||
|
if file_bytes is not None:
|
||||||
|
# Move binary if path changed
|
||||||
|
old_path = _storage_path(hw_type, old_channel, old_version)
|
||||||
|
new_path = _storage_path(hw_type, effective_channel, effective_version)
|
||||||
|
if old_path != new_path and old_path.exists():
|
||||||
|
old_path.unlink()
|
||||||
|
try:
|
||||||
|
old_path.parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
new_path.write_bytes(file_bytes)
|
||||||
|
updates["sha256"] = hashlib.sha256(file_bytes).hexdigest()
|
||||||
|
updates["size_bytes"] = len(file_bytes)
|
||||||
|
elif (channel is not None and channel != old_channel) or (version is not None and version != old_version):
|
||||||
|
# Path changed but no new file — move existing binary
|
||||||
|
old_path = _storage_path(hw_type, old_channel, old_version)
|
||||||
|
new_path = _storage_path(hw_type, effective_channel, effective_version)
|
||||||
|
if old_path.exists() and old_path != new_path:
|
||||||
|
new_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
old_path.rename(new_path)
|
||||||
|
try:
|
||||||
|
old_path.parent.rmdir()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if updates:
|
||||||
|
doc_ref.update(updates)
|
||||||
|
|
||||||
|
return _doc_to_firmware_version(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
def delete_firmware(doc_id: str) -> None:
|
def delete_firmware(doc_id: str) -> None:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
doc_ref = db.collection(COLLECTION).document(doc_id)
|
doc_ref = db.collection(COLLECTION).document(doc_id)
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ from crm.comms_router import router as crm_comms_router
|
|||||||
from crm.media_router import router as crm_media_router
|
from crm.media_router import router as crm_media_router
|
||||||
from crm.nextcloud_router import router as crm_nextcloud_router
|
from crm.nextcloud_router import router as crm_nextcloud_router
|
||||||
from crm.quotations_router import router as crm_quotations_router
|
from crm.quotations_router import router as crm_quotations_router
|
||||||
|
from public.router import router as public_router
|
||||||
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
from crm.nextcloud import close_client as close_nextcloud_client, keepalive_ping as nextcloud_keepalive
|
||||||
from crm.mail_accounts import get_mail_accounts
|
from crm.mail_accounts import get_mail_accounts
|
||||||
from mqtt.client import mqtt_manager
|
from mqtt.client import mqtt_manager
|
||||||
@@ -67,6 +68,7 @@ app.include_router(crm_comms_router)
|
|||||||
app.include_router(crm_media_router)
|
app.include_router(crm_media_router)
|
||||||
app.include_router(crm_nextcloud_router)
|
app.include_router(crm_nextcloud_router)
|
||||||
app.include_router(crm_quotations_router)
|
app.include_router(crm_quotations_router)
|
||||||
|
app.include_router(public_router)
|
||||||
|
|
||||||
|
|
||||||
async def nextcloud_keepalive_loop():
|
async def nextcloud_keepalive_loop():
|
||||||
|
|||||||
@@ -55,6 +55,13 @@ class MfgStatus(str, Enum):
|
|||||||
decommissioned = "decommissioned"
|
decommissioned = "decommissioned"
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleEntry(BaseModel):
|
||||||
|
status_id: str
|
||||||
|
date: str # ISO 8601 UTC string
|
||||||
|
note: Optional[str] = None
|
||||||
|
set_by: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class BatchCreate(BaseModel):
|
class BatchCreate(BaseModel):
|
||||||
board_type: BoardType
|
board_type: BoardType
|
||||||
board_version: str = Field(
|
board_version: str = Field(
|
||||||
@@ -84,6 +91,9 @@ class DeviceInventoryItem(BaseModel):
|
|||||||
owner: Optional[str] = None
|
owner: Optional[str] = None
|
||||||
assigned_to: Optional[str] = None
|
assigned_to: Optional[str] = None
|
||||||
device_name: Optional[str] = None
|
device_name: Optional[str] = None
|
||||||
|
lifecycle_history: Optional[List["LifecycleEntry"]] = None
|
||||||
|
customer_id: Optional[str] = None
|
||||||
|
user_list: Optional[List[str]] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceInventoryListResponse(BaseModel):
|
class DeviceInventoryListResponse(BaseModel):
|
||||||
@@ -94,11 +104,19 @@ class DeviceInventoryListResponse(BaseModel):
|
|||||||
class DeviceStatusUpdate(BaseModel):
|
class DeviceStatusUpdate(BaseModel):
|
||||||
status: MfgStatus
|
status: MfgStatus
|
||||||
note: Optional[str] = None
|
note: Optional[str] = None
|
||||||
|
force_claimed: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DeviceAssign(BaseModel):
|
class DeviceAssign(BaseModel):
|
||||||
customer_email: str
|
customer_id: str
|
||||||
customer_name: Optional[str] = None
|
|
||||||
|
|
||||||
|
class CustomerSearchResult(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str = ""
|
||||||
|
email: str = ""
|
||||||
|
organization: str = ""
|
||||||
|
phone: str = ""
|
||||||
|
|
||||||
|
|
||||||
class RecentActivityItem(BaseModel):
|
class RecentActivityItem(BaseModel):
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File, Form
|
from fastapi import APIRouter, Depends, Query, HTTPException, UploadFile, File
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission
|
||||||
@@ -14,9 +15,23 @@ from manufacturing.models import (
|
|||||||
from manufacturing import service
|
from manufacturing import service
|
||||||
from manufacturing import audit
|
from manufacturing import audit
|
||||||
from shared.exceptions import NotFoundError
|
from shared.exceptions import NotFoundError
|
||||||
|
from shared.firebase import get_db as get_firestore
|
||||||
|
|
||||||
|
|
||||||
|
class LifecycleEntryPatch(BaseModel):
|
||||||
|
index: int
|
||||||
|
date: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
|
class LifecycleEntryCreate(BaseModel):
|
||||||
|
status_id: str
|
||||||
|
date: Optional[str] = None
|
||||||
|
note: Optional[str] = None
|
||||||
|
|
||||||
VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"}
|
VALID_FLASH_ASSETS = {"bootloader.bin", "partitions.bin"}
|
||||||
VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"}
|
VALID_HW_TYPES_MFG = {"vesper", "vesper_plus", "vesper_pro", "agnus", "agnus_mini", "chronos", "chronos_pro"}
|
||||||
|
# Bespoke UIDs are dynamic — we allow any non-empty slug that doesn't clash with
|
||||||
|
# a standard hw_type name. The flash-asset upload endpoint checks this below.
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
router = APIRouter(prefix="/api/manufacturing", tags=["manufacturing"])
|
||||||
|
|
||||||
@@ -83,13 +98,75 @@ def get_device(
|
|||||||
return service.get_device_by_sn(sn)
|
return service.get_device_by_sn(sn)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/search")
|
||||||
|
def search_customers(
|
||||||
|
q: str = Query(""),
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
|
):
|
||||||
|
"""Search CRM customers by name, email, phone, organization, or tags."""
|
||||||
|
results = service.search_customers(q)
|
||||||
|
return {"results": results}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/customers/{customer_id}")
|
||||||
|
def get_customer(
|
||||||
|
customer_id: str,
|
||||||
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
|
):
|
||||||
|
"""Get a single CRM customer by ID."""
|
||||||
|
db = get_firestore()
|
||||||
|
doc = db.collection("crm_customers").document(customer_id).get()
|
||||||
|
if not doc.exists:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
loc = data.get("location") or {}
|
||||||
|
city = loc.get("city") if isinstance(loc, dict) else None
|
||||||
|
return {
|
||||||
|
"id": doc.id,
|
||||||
|
"name": data.get("name") or "",
|
||||||
|
"surname": data.get("surname") or "",
|
||||||
|
"email": data.get("email") or "",
|
||||||
|
"organization": data.get("organization") or "",
|
||||||
|
"phone": data.get("phone") or "",
|
||||||
|
"city": city or "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
|
@router.patch("/devices/{sn}/status", response_model=DeviceInventoryItem)
|
||||||
async def update_status(
|
async def update_status(
|
||||||
sn: str,
|
sn: str,
|
||||||
body: DeviceStatusUpdate,
|
body: DeviceStatusUpdate,
|
||||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||||
):
|
):
|
||||||
result = service.update_device_status(sn, body)
|
# Guard: claimed requires at least one user in user_list
|
||||||
|
# (allow if explicitly force_claimed=true, which the mfg UI sets after adding a user manually)
|
||||||
|
if body.status.value == "claimed":
|
||||||
|
db = get_firestore()
|
||||||
|
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||||
|
if docs:
|
||||||
|
data = docs[0].to_dict() or {}
|
||||||
|
user_list = data.get("user_list", []) or []
|
||||||
|
if not user_list and not getattr(body, "force_claimed", False):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot set status to 'claimed': device has no users in user_list. "
|
||||||
|
"Assign a user first, then set to Claimed.",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Guard: sold requires a customer assigned
|
||||||
|
if body.status.value == "sold":
|
||||||
|
db = get_firestore()
|
||||||
|
docs = list(db.collection("devices").where("serial_number", "==", sn).limit(1).stream())
|
||||||
|
if docs:
|
||||||
|
data = docs[0].to_dict() or {}
|
||||||
|
if not data.get("customer_id"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail="Cannot set status to 'sold' without an assigned customer. "
|
||||||
|
"Use the 'Assign to Customer' action first.",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = service.update_device_status(sn, body, set_by=user.email)
|
||||||
await audit.log_action(
|
await audit.log_action(
|
||||||
admin_user=user.email,
|
admin_user=user.email,
|
||||||
action="status_updated",
|
action="status_updated",
|
||||||
@@ -99,12 +176,91 @@ async def update_status(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem)
|
||||||
|
async def patch_lifecycle_entry(
|
||||||
|
sn: str,
|
||||||
|
body: LifecycleEntryPatch,
|
||||||
|
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||||
|
):
|
||||||
|
"""Edit the date and/or note of a lifecycle history entry by index."""
|
||||||
|
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")
|
||||||
|
doc_ref = docs[0].reference
|
||||||
|
data = docs[0].to_dict() or {}
|
||||||
|
history = data.get("lifecycle_history") or []
|
||||||
|
if body.index < 0 or body.index >= len(history):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
|
||||||
|
if body.date is not None:
|
||||||
|
history[body.index]["date"] = body.date
|
||||||
|
if body.note is not None:
|
||||||
|
history[body.index]["note"] = body.note
|
||||||
|
doc_ref.update({"lifecycle_history": history})
|
||||||
|
from manufacturing.service import _doc_to_inventory_item
|
||||||
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/devices/{sn}/lifecycle", response_model=DeviceInventoryItem, status_code=201)
|
||||||
|
async def create_lifecycle_entry(
|
||||||
|
sn: str,
|
||||||
|
body: LifecycleEntryCreate,
|
||||||
|
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||||
|
):
|
||||||
|
"""Create a lifecycle history entry for a step that has no entry yet (on-the-fly)."""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
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")
|
||||||
|
doc_ref = docs[0].reference
|
||||||
|
data = docs[0].to_dict() or {}
|
||||||
|
history = data.get("lifecycle_history") or []
|
||||||
|
new_entry = {
|
||||||
|
"status_id": body.status_id,
|
||||||
|
"date": body.date or datetime.now(timezone.utc).isoformat(),
|
||||||
|
"note": body.note,
|
||||||
|
"set_by": user.email,
|
||||||
|
}
|
||||||
|
history.append(new_entry)
|
||||||
|
doc_ref.update({"lifecycle_history": history})
|
||||||
|
from manufacturing.service import _doc_to_inventory_item
|
||||||
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/devices/{sn}/lifecycle/{index}", response_model=DeviceInventoryItem)
|
||||||
|
async def delete_lifecycle_entry(
|
||||||
|
sn: str,
|
||||||
|
index: int,
|
||||||
|
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||||
|
):
|
||||||
|
"""Delete a lifecycle history entry by index. Cannot delete the entry for the current status."""
|
||||||
|
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")
|
||||||
|
doc_ref = docs[0].reference
|
||||||
|
data = docs[0].to_dict() or {}
|
||||||
|
history = data.get("lifecycle_history") or []
|
||||||
|
if index < 0 or index >= len(history):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid lifecycle entry index")
|
||||||
|
current_status = data.get("mfg_status", "")
|
||||||
|
if history[index].get("status_id") == current_status:
|
||||||
|
raise HTTPException(status_code=400, detail="Cannot delete the entry for the current status. Change the status first.")
|
||||||
|
history.pop(index)
|
||||||
|
doc_ref.update({"lifecycle_history": history})
|
||||||
|
from manufacturing.service import _doc_to_inventory_item
|
||||||
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices/{sn}/nvs.bin")
|
@router.get("/devices/{sn}/nvs.bin")
|
||||||
async def download_nvs(
|
async def download_nvs(
|
||||||
sn: str,
|
sn: str,
|
||||||
|
hw_type_override: Optional[str] = Query(None, description="Override hw_type written to NVS (for bespoke firmware)"),
|
||||||
|
hw_revision_override: Optional[str] = Query(None, description="Override hw_revision written to NVS (for bespoke firmware)"),
|
||||||
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
):
|
):
|
||||||
binary = service.get_nvs_binary(sn)
|
binary = service.get_nvs_binary(sn, hw_type_override=hw_type_override, hw_revision_override=hw_revision_override)
|
||||||
await audit.log_action(
|
await audit.log_action(
|
||||||
admin_user=user.email,
|
admin_user=user.email,
|
||||||
action="device_flashed",
|
action="device_flashed",
|
||||||
@@ -123,12 +279,15 @@ async def assign_device(
|
|||||||
body: DeviceAssign,
|
body: DeviceAssign,
|
||||||
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
user: TokenPayload = Depends(require_permission("manufacturing", "edit")),
|
||||||
):
|
):
|
||||||
|
try:
|
||||||
result = service.assign_device(sn, body)
|
result = service.assign_device(sn, body)
|
||||||
|
except NotFoundError as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
await audit.log_action(
|
await audit.log_action(
|
||||||
admin_user=user.email,
|
admin_user=user.email,
|
||||||
action="device_assigned",
|
action="device_assigned",
|
||||||
serial_number=sn,
|
serial_number=sn,
|
||||||
detail={"customer_email": body.customer_email, "customer_name": body.customer_name},
|
detail={"customer_id": body.customer_id},
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -201,8 +360,9 @@ async def upload_flash_asset(
|
|||||||
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
|
and .pio/build/{env}/partitions.bin). Upload them once per hw_type after
|
||||||
each PlatformIO build that changes the partition layout.
|
each PlatformIO build that changes the partition layout.
|
||||||
"""
|
"""
|
||||||
if hw_type not in VALID_HW_TYPES_MFG:
|
# hw_type can be a standard board type OR a bespoke UID (any non-empty slug)
|
||||||
raise HTTPException(status_code=400, detail=f"Invalid hw_type. Must be one of: {', '.join(sorted(VALID_HW_TYPES_MFG))}")
|
if not hw_type or len(hw_type) > 128:
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid hw_type/bespoke UID.")
|
||||||
if asset not in VALID_FLASH_ASSETS:
|
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))}")
|
raise HTTPException(status_code=400, detail=f"Invalid asset. Must be one of: {', '.join(sorted(VALID_FLASH_ASSETS))}")
|
||||||
data = await file.read()
|
data = await file.read()
|
||||||
@@ -212,34 +372,38 @@ async def upload_flash_asset(
|
|||||||
@router.get("/devices/{sn}/bootloader.bin")
|
@router.get("/devices/{sn}/bootloader.bin")
|
||||||
def download_bootloader(
|
def download_bootloader(
|
||||||
sn: str,
|
sn: str,
|
||||||
|
hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"),
|
||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
):
|
):
|
||||||
"""Return the bootloader.bin for this device's hw_type (flashed at 0x1000)."""
|
"""Return the bootloader.bin for this device's hw_type (flashed at 0x1000)."""
|
||||||
item = service.get_device_by_sn(sn)
|
item = service.get_device_by_sn(sn)
|
||||||
|
hw_type = hw_type_override or item.hw_type
|
||||||
try:
|
try:
|
||||||
data = service.get_flash_asset(item.hw_type, "bootloader.bin")
|
data = service.get_flash_asset(hw_type, "bootloader.bin")
|
||||||
except NotFoundError as e:
|
except NotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
return Response(
|
return Response(
|
||||||
content=data,
|
content=data,
|
||||||
media_type="application/octet-stream",
|
media_type="application/octet-stream",
|
||||||
headers={"Content-Disposition": f'attachment; filename="bootloader_{item.hw_type}.bin"'},
|
headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/devices/{sn}/partitions.bin")
|
@router.get("/devices/{sn}/partitions.bin")
|
||||||
def download_partitions(
|
def download_partitions(
|
||||||
sn: str,
|
sn: str,
|
||||||
|
hw_type_override: Optional[str] = Query(None, description="Override hw_type for flash asset lookup (for bespoke firmware)"),
|
||||||
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
_user: TokenPayload = Depends(require_permission("manufacturing", "view")),
|
||||||
):
|
):
|
||||||
"""Return the partitions.bin for this device's hw_type (flashed at 0x8000)."""
|
"""Return the partitions.bin for this device's hw_type (flashed at 0x8000)."""
|
||||||
item = service.get_device_by_sn(sn)
|
item = service.get_device_by_sn(sn)
|
||||||
|
hw_type = hw_type_override or item.hw_type
|
||||||
try:
|
try:
|
||||||
data = service.get_flash_asset(item.hw_type, "partitions.bin")
|
data = service.get_flash_asset(hw_type, "partitions.bin")
|
||||||
except NotFoundError as e:
|
except NotFoundError as e:
|
||||||
raise HTTPException(status_code=404, detail=str(e))
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
return Response(
|
return Response(
|
||||||
content=data,
|
content=data,
|
||||||
media_type="application/octet-stream",
|
media_type="application/octet-stream",
|
||||||
headers={"Content-Disposition": f'attachment; filename="partitions_{item.hw_type}.bin"'},
|
headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ def _get_existing_sns(db) -> set:
|
|||||||
return existing
|
return existing
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_user_list(raw_list: list) -> list[str]:
|
||||||
|
"""Convert user_list entries (DocumentReferences or path strings) to plain user ID strings."""
|
||||||
|
from google.cloud.firestore_v1 import DocumentReference
|
||||||
|
result = []
|
||||||
|
for entry in raw_list:
|
||||||
|
if isinstance(entry, DocumentReference):
|
||||||
|
result.append(entry.id)
|
||||||
|
elif isinstance(entry, str):
|
||||||
|
result.append(entry.split("/")[-1])
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
||||||
data = doc.to_dict() or {}
|
data = doc.to_dict() or {}
|
||||||
created_raw = data.get("created_at")
|
created_raw = data.get("created_at")
|
||||||
@@ -52,6 +64,9 @@ def _doc_to_inventory_item(doc) -> DeviceInventoryItem:
|
|||||||
owner=data.get("owner"),
|
owner=data.get("owner"),
|
||||||
assigned_to=data.get("assigned_to"),
|
assigned_to=data.get("assigned_to"),
|
||||||
device_name=data.get("device_name") or None,
|
device_name=data.get("device_name") or None,
|
||||||
|
lifecycle_history=data.get("lifecycle_history") or [],
|
||||||
|
customer_id=data.get("customer_id"),
|
||||||
|
user_list=_resolve_user_list(data.get("user_list") or []),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -80,11 +95,19 @@ def create_batch(data: BatchCreate) -> BatchResponse:
|
|||||||
"created_at": now,
|
"created_at": now,
|
||||||
"owner": None,
|
"owner": None,
|
||||||
"assigned_to": None,
|
"assigned_to": None,
|
||||||
"users_list": [],
|
"user_list": [],
|
||||||
# Legacy fields left empty so existing device views don't break
|
# Legacy fields left empty so existing device views don't break
|
||||||
"device_name": "",
|
"device_name": "",
|
||||||
"device_location": "",
|
"device_location": "",
|
||||||
"is_Online": False,
|
"is_Online": False,
|
||||||
|
"lifecycle_history": [
|
||||||
|
{
|
||||||
|
"status_id": "manufactured",
|
||||||
|
"date": now.isoformat(),
|
||||||
|
"note": None,
|
||||||
|
"set_by": None,
|
||||||
|
}
|
||||||
|
],
|
||||||
})
|
})
|
||||||
serial_numbers.append(sn)
|
serial_numbers.append(sn)
|
||||||
|
|
||||||
@@ -135,14 +158,31 @@ def get_device_by_sn(sn: str) -> DeviceInventoryItem:
|
|||||||
return _doc_to_inventory_item(docs[0])
|
return _doc_to_inventory_item(docs[0])
|
||||||
|
|
||||||
|
|
||||||
def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryItem:
|
def update_device_status(sn: str, data: DeviceStatusUpdate, set_by: str | None = None) -> DeviceInventoryItem:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||||
if not docs:
|
if not docs:
|
||||||
raise NotFoundError("Device")
|
raise NotFoundError("Device")
|
||||||
|
|
||||||
doc_ref = docs[0].reference
|
doc_ref = docs[0].reference
|
||||||
update = {"mfg_status": data.status.value}
|
doc_data = docs[0].to_dict() or {}
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
history = doc_data.get("lifecycle_history") or []
|
||||||
|
|
||||||
|
# Append new lifecycle entry
|
||||||
|
new_entry = {
|
||||||
|
"status_id": data.status.value,
|
||||||
|
"date": now,
|
||||||
|
"note": data.note if data.note else None,
|
||||||
|
"set_by": set_by,
|
||||||
|
}
|
||||||
|
history.append(new_entry)
|
||||||
|
|
||||||
|
update = {
|
||||||
|
"mfg_status": data.status.value,
|
||||||
|
"lifecycle_history": history,
|
||||||
|
}
|
||||||
if data.note:
|
if data.note:
|
||||||
update["mfg_status_note"] = data.note
|
update["mfg_status_note"] = data.note
|
||||||
doc_ref.update(update)
|
doc_ref.update(update)
|
||||||
@@ -150,47 +190,114 @@ def update_device_status(sn: str, data: DeviceStatusUpdate) -> DeviceInventoryIt
|
|||||||
return _doc_to_inventory_item(doc_ref.get())
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
def get_nvs_binary(sn: str) -> bytes:
|
def get_nvs_binary(sn: str, hw_type_override: str | None = None, hw_revision_override: str | None = None) -> bytes:
|
||||||
item = get_device_by_sn(sn)
|
item = get_device_by_sn(sn)
|
||||||
return generate_nvs_binary(
|
return generate_nvs_binary(
|
||||||
serial_number=item.serial_number,
|
serial_number=item.serial_number,
|
||||||
hw_type=item.hw_type,
|
hw_family=hw_type_override if hw_type_override else item.hw_type,
|
||||||
hw_version=item.hw_version,
|
hw_revision=hw_revision_override if hw_revision_override else item.hw_version,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
|
def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem:
|
||||||
from utils.email import send_device_assignment_invite
|
"""Assign a device to a customer by customer_id.
|
||||||
|
|
||||||
|
- Stores customer_id on the device doc.
|
||||||
|
- Adds the device to the customer's owned_items list.
|
||||||
|
- Sets mfg_status to 'sold' unless device is already 'claimed'.
|
||||||
|
"""
|
||||||
db = get_db()
|
db = get_db()
|
||||||
|
CRM_COLLECTION = "crm_customers"
|
||||||
|
|
||||||
|
# Get device doc
|
||||||
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
docs = list(db.collection(COLLECTION).where("serial_number", "==", sn).limit(1).stream())
|
||||||
if not docs:
|
if not docs:
|
||||||
raise NotFoundError("Device")
|
raise NotFoundError("Device")
|
||||||
|
|
||||||
doc_data = docs[0].to_dict() or {}
|
doc_data = docs[0].to_dict() or {}
|
||||||
doc_ref = docs[0].reference
|
doc_ref = docs[0].reference
|
||||||
doc_ref.update({
|
current_status = doc_data.get("mfg_status", "manufactured")
|
||||||
"owner": data.customer_email,
|
|
||||||
"assigned_to": data.customer_email,
|
# Get customer doc
|
||||||
"mfg_status": "sold",
|
customer_ref = db.collection(CRM_COLLECTION).document(data.customer_id)
|
||||||
|
customer_doc = customer_ref.get()
|
||||||
|
if not customer_doc.exists:
|
||||||
|
raise NotFoundError("Customer")
|
||||||
|
customer_data = customer_doc.to_dict() or {}
|
||||||
|
|
||||||
|
# Determine new status: don't downgrade claimed → sold
|
||||||
|
new_status = current_status if current_status == "claimed" else "sold"
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
history = doc_data.get("lifecycle_history") or []
|
||||||
|
history.append({
|
||||||
|
"status_id": new_status,
|
||||||
|
"date": now,
|
||||||
|
"note": "Assigned to customer",
|
||||||
|
"set_by": None,
|
||||||
})
|
})
|
||||||
|
|
||||||
hw_type = doc_data.get("hw_type", "")
|
doc_ref.update({
|
||||||
device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device")
|
"customer_id": data.customer_id,
|
||||||
|
"mfg_status": new_status,
|
||||||
|
"lifecycle_history": history,
|
||||||
|
})
|
||||||
|
|
||||||
try:
|
# Add to customer's owned_items (avoid duplicates)
|
||||||
send_device_assignment_invite(
|
owned_items = customer_data.get("owned_items", []) or []
|
||||||
customer_email=data.customer_email,
|
device_doc_id = docs[0].id
|
||||||
serial_number=sn,
|
already_assigned = any(
|
||||||
device_name=device_name,
|
item.get("type") == "console_device"
|
||||||
customer_name=data.customer_name,
|
and item.get("console_device", {}).get("device_id") == device_doc_id
|
||||||
|
for item in owned_items
|
||||||
)
|
)
|
||||||
except Exception as exc:
|
if not already_assigned:
|
||||||
logger.error("Assignment succeeded but email failed for %s → %s: %s", sn, data.customer_email, exc)
|
device_name = doc_data.get("device_name") or BOARD_TYPE_LABELS.get(doc_data.get("hw_type", ""), sn)
|
||||||
|
owned_items.append({
|
||||||
|
"type": "console_device",
|
||||||
|
"console_device": {
|
||||||
|
"device_id": device_doc_id,
|
||||||
|
"serial_number": sn,
|
||||||
|
"label": device_name,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
customer_ref.update({"owned_items": owned_items})
|
||||||
|
|
||||||
return _doc_to_inventory_item(doc_ref.get())
|
return _doc_to_inventory_item(doc_ref.get())
|
||||||
|
|
||||||
|
|
||||||
|
def search_customers(q: str) -> list:
|
||||||
|
"""Search crm_customers by name, email, phone, organization, or tags."""
|
||||||
|
db = get_db()
|
||||||
|
CRM_COLLECTION = "crm_customers"
|
||||||
|
docs = db.collection(CRM_COLLECTION).stream()
|
||||||
|
results = []
|
||||||
|
q_lower = q.lower().strip()
|
||||||
|
for doc in docs:
|
||||||
|
data = doc.to_dict() or {}
|
||||||
|
loc = data.get("location") or {}
|
||||||
|
loc = loc if isinstance(loc, dict) else {}
|
||||||
|
city = loc.get("city") or ""
|
||||||
|
searchable = " ".join(filter(None, [
|
||||||
|
data.get("name"), data.get("surname"),
|
||||||
|
data.get("email"), data.get("phone"), data.get("organization"),
|
||||||
|
loc.get("address"), loc.get("city"), loc.get("postal_code"),
|
||||||
|
loc.get("region"), loc.get("country"),
|
||||||
|
" ".join(data.get("tags") or []),
|
||||||
|
])).lower()
|
||||||
|
if not q_lower or q_lower in searchable:
|
||||||
|
results.append({
|
||||||
|
"id": doc.id,
|
||||||
|
"name": data.get("name") or "",
|
||||||
|
"surname": data.get("surname") or "",
|
||||||
|
"email": data.get("email") or "",
|
||||||
|
"organization": data.get("organization") or "",
|
||||||
|
"phone": data.get("phone") or "",
|
||||||
|
"city": city or "",
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
def get_stats() -> ManufacturingStats:
|
def get_stats() -> ManufacturingStats:
|
||||||
db = get_db()
|
db = get_db()
|
||||||
docs = list(db.collection(COLLECTION).stream())
|
docs = list(db.collection(COLLECTION).stream())
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MelodyInfo(BaseModel):
|
|||||||
isTrueRing: bool = False
|
isTrueRing: bool = False
|
||||||
previewURL: str = ""
|
previewURL: str = ""
|
||||||
archetype_csv: Optional[str] = None
|
archetype_csv: Optional[str] = None
|
||||||
|
outdated_archetype: bool = False
|
||||||
|
|
||||||
|
|
||||||
class MelodyAttributes(BaseModel):
|
class MelodyAttributes(BaseModel):
|
||||||
|
|||||||
@@ -146,6 +146,23 @@ async def get_files(
|
|||||||
return service.get_storage_files(melody_id, melody.uid)
|
return service.get_storage_files(melody_id, melody.uid)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{melody_id}/set-outdated", response_model=MelodyInDB)
|
||||||
|
async def set_outdated(
|
||||||
|
melody_id: str,
|
||||||
|
outdated: bool = Query(...),
|
||||||
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
|
):
|
||||||
|
"""Manually set or clear the outdated_archetype flag on a melody."""
|
||||||
|
melody = await service.get_melody(melody_id)
|
||||||
|
info = melody.information.model_dump()
|
||||||
|
info["outdated_archetype"] = outdated
|
||||||
|
return await service.update_melody(
|
||||||
|
melody_id,
|
||||||
|
MelodyUpdate(information=MelodyInfo(**info)),
|
||||||
|
actor_name=_user.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{melody_id}/download/binary")
|
@router.get("/{melody_id}/download/binary")
|
||||||
async def download_binary_file(
|
async def download_binary_file(
|
||||||
melody_id: str,
|
melody_id: str,
|
||||||
|
|||||||
0
backend/public/__init__.py
Normal file
0
backend/public/__init__.py
Normal file
208
backend/public/router.py
Normal file
208
backend/public/router.py
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
"""
|
||||||
|
Public (no-auth) endpoints for CloudFlash and feature gate checks.
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from settings.public_features_service import get_public_features
|
||||||
|
from firmware.service import list_firmware
|
||||||
|
from utils.nvs_generator import generate as generate_nvs
|
||||||
|
from manufacturing.service import get_device_by_sn
|
||||||
|
from shared.exceptions import NotFoundError
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/public", tags=["public"])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feature gate ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class CloudFlashStatus(BaseModel):
|
||||||
|
enabled: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cloudflash/status", response_model=CloudFlashStatus)
|
||||||
|
async def cloudflash_status():
|
||||||
|
"""Returns whether the CloudFlash public page is currently enabled."""
|
||||||
|
settings = get_public_features()
|
||||||
|
return CloudFlashStatus(enabled=settings.cloudflash_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
def _require_cloudflash_enabled():
|
||||||
|
"""Raises 403 if CloudFlash is disabled."""
|
||||||
|
settings = get_public_features()
|
||||||
|
if not settings.cloudflash_enabled:
|
||||||
|
raise HTTPException(status_code=403, detail="CloudFlash is currently disabled.")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public firmware list ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class PublicFirmwareOption(BaseModel):
|
||||||
|
hw_type: str
|
||||||
|
hw_type_label: str
|
||||||
|
channel: str
|
||||||
|
version: str
|
||||||
|
download_url: str
|
||||||
|
|
||||||
|
|
||||||
|
HW_TYPE_LABELS = {
|
||||||
|
"vesper": "Vesper",
|
||||||
|
"vesper_plus": "Vesper Plus",
|
||||||
|
"vesper_pro": "Vesper Pro",
|
||||||
|
"agnus": "Agnus",
|
||||||
|
"agnus_mini": "Agnus Mini",
|
||||||
|
"chronos": "Chronos",
|
||||||
|
"chronos_pro": "Chronos Pro",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cloudflash/firmware", response_model=List[PublicFirmwareOption])
|
||||||
|
async def list_public_firmware():
|
||||||
|
"""
|
||||||
|
Returns all available firmware options (is_latest=True, non-bespoke, stable channel only).
|
||||||
|
No authentication required — used by the public CloudFlash page.
|
||||||
|
"""
|
||||||
|
_require_cloudflash_enabled()
|
||||||
|
|
||||||
|
all_fw = list_firmware()
|
||||||
|
options = []
|
||||||
|
for fw in all_fw:
|
||||||
|
if not fw.is_latest:
|
||||||
|
continue
|
||||||
|
if fw.hw_type == "bespoke":
|
||||||
|
continue
|
||||||
|
if fw.channel != "stable":
|
||||||
|
continue
|
||||||
|
options.append(PublicFirmwareOption(
|
||||||
|
hw_type=fw.hw_type,
|
||||||
|
hw_type_label=HW_TYPE_LABELS.get(fw.hw_type, fw.hw_type.replace("_", " ").title()),
|
||||||
|
channel=fw.channel,
|
||||||
|
version=fw.version,
|
||||||
|
download_url=f"/api/firmware/{fw.hw_type}/{fw.channel}/{fw.version}/firmware.bin",
|
||||||
|
))
|
||||||
|
|
||||||
|
# Sort by hw_type label
|
||||||
|
options.sort(key=lambda x: x.hw_type_label)
|
||||||
|
return options
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public serial number validation ──────────────────────────────────────────
|
||||||
|
|
||||||
|
class SerialValidationResult(BaseModel):
|
||||||
|
valid: bool
|
||||||
|
hw_type: Optional[str] = None
|
||||||
|
hw_type_label: Optional[str] = None
|
||||||
|
hw_version: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cloudflash/validate-serial/{serial_number}", response_model=SerialValidationResult)
|
||||||
|
async def validate_serial(serial_number: str):
|
||||||
|
"""
|
||||||
|
Check whether a serial number exists in the device database.
|
||||||
|
Returns hw_type info if found so the frontend can confirm it matches the user's selection.
|
||||||
|
No sensitive device data is returned.
|
||||||
|
"""
|
||||||
|
_require_cloudflash_enabled()
|
||||||
|
|
||||||
|
sn = serial_number.strip().upper()
|
||||||
|
try:
|
||||||
|
device = get_device_by_sn(sn)
|
||||||
|
return SerialValidationResult(
|
||||||
|
valid=True,
|
||||||
|
hw_type=device.hw_type,
|
||||||
|
hw_type_label=HW_TYPE_LABELS.get(device.hw_type, device.hw_type.replace("_", " ").title()),
|
||||||
|
hw_version=device.hw_version,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return SerialValidationResult(valid=False)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public NVS generation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class NvsRequest(BaseModel):
|
||||||
|
serial_number: str
|
||||||
|
hw_type: str
|
||||||
|
hw_revision: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/cloudflash/nvs.bin")
|
||||||
|
async def generate_public_nvs(body: NvsRequest):
|
||||||
|
"""
|
||||||
|
Generate an NVS binary for a given serial number + hardware info.
|
||||||
|
No authentication required — used by the public CloudFlash page for Full Wipe flash.
|
||||||
|
The serial number is provided by the user (they read it from the sticker on their device).
|
||||||
|
"""
|
||||||
|
_require_cloudflash_enabled()
|
||||||
|
|
||||||
|
sn = body.serial_number.strip().upper()
|
||||||
|
if not sn:
|
||||||
|
raise HTTPException(status_code=422, detail="Serial number is required.")
|
||||||
|
|
||||||
|
hw_type = body.hw_type.strip().lower()
|
||||||
|
hw_revision = body.hw_revision.strip()
|
||||||
|
|
||||||
|
if not hw_type or not hw_revision:
|
||||||
|
raise HTTPException(status_code=422, detail="hw_type and hw_revision are required.")
|
||||||
|
|
||||||
|
try:
|
||||||
|
nvs_bytes = generate_nvs(
|
||||||
|
serial_number=sn,
|
||||||
|
hw_family=hw_type,
|
||||||
|
hw_revision=hw_revision,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"NVS generation failed: {str(e)}")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=nvs_bytes,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{sn}_nvs.bin"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public flash assets (bootloader + partitions) ─────────────────────────────
|
||||||
|
|
||||||
|
@router.get("/cloudflash/{hw_type}/bootloader.bin")
|
||||||
|
async def get_public_bootloader(hw_type: str):
|
||||||
|
"""
|
||||||
|
Serve the bootloader binary for a given hw_type.
|
||||||
|
No authentication required — used by the public CloudFlash page.
|
||||||
|
"""
|
||||||
|
_require_cloudflash_enabled()
|
||||||
|
|
||||||
|
import os
|
||||||
|
from config import settings as cfg
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "bootloader.bin"
|
||||||
|
if not asset_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Bootloader not found for {hw_type}.")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=asset_path.read_bytes(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="bootloader_{hw_type}.bin"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/cloudflash/{hw_type}/partitions.bin")
|
||||||
|
async def get_public_partitions(hw_type: str):
|
||||||
|
"""
|
||||||
|
Serve the partition table binary for a given hw_type.
|
||||||
|
No authentication required — used by the public CloudFlash page.
|
||||||
|
"""
|
||||||
|
_require_cloudflash_enabled()
|
||||||
|
|
||||||
|
import os
|
||||||
|
from config import settings as cfg
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
asset_path = Path(cfg.flash_assets_storage_path) / hw_type / "partitions.bin"
|
||||||
|
if not asset_path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail=f"Partition table not found for {hw_type}.")
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
content=asset_path.read_bytes(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="partitions_{hw_type}.bin"'},
|
||||||
|
)
|
||||||
10
backend/settings/public_features_models.py
Normal file
10
backend/settings/public_features_models.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PublicFeaturesSettings(BaseModel):
|
||||||
|
cloudflash_enabled: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PublicFeaturesSettingsUpdate(BaseModel):
|
||||||
|
cloudflash_enabled: Optional[bool] = None
|
||||||
31
backend/settings/public_features_service.py
Normal file
31
backend/settings/public_features_service.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from shared.firebase import get_db
|
||||||
|
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
|
||||||
|
|
||||||
|
COLLECTION = "admin_settings"
|
||||||
|
DOC_ID = "public_features"
|
||||||
|
|
||||||
|
|
||||||
|
def get_public_features() -> PublicFeaturesSettings:
|
||||||
|
"""Get public features settings from Firestore. Creates defaults if not found."""
|
||||||
|
db = get_db()
|
||||||
|
doc = db.collection(COLLECTION).document(DOC_ID).get()
|
||||||
|
if doc.exists:
|
||||||
|
return PublicFeaturesSettings(**doc.to_dict())
|
||||||
|
defaults = PublicFeaturesSettings()
|
||||||
|
db.collection(COLLECTION).document(DOC_ID).set(defaults.model_dump())
|
||||||
|
return defaults
|
||||||
|
|
||||||
|
|
||||||
|
def update_public_features(data: PublicFeaturesSettingsUpdate) -> PublicFeaturesSettings:
|
||||||
|
"""Update public features settings. Only provided fields are updated."""
|
||||||
|
db = get_db()
|
||||||
|
doc_ref = db.collection(COLLECTION).document(DOC_ID)
|
||||||
|
doc = doc_ref.get()
|
||||||
|
|
||||||
|
existing = doc.to_dict() if doc.exists else PublicFeaturesSettings().model_dump()
|
||||||
|
update_data = data.model_dump(exclude_none=True)
|
||||||
|
existing.update(update_data)
|
||||||
|
|
||||||
|
normalized = PublicFeaturesSettings(**existing)
|
||||||
|
doc_ref.set(normalized.model_dump())
|
||||||
|
return normalized
|
||||||
@@ -1,8 +1,11 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from auth.models import TokenPayload
|
from auth.models import TokenPayload
|
||||||
from auth.dependencies import require_permission
|
from auth.dependencies import require_permission, require_roles
|
||||||
|
from auth.models import Role
|
||||||
from settings.models import MelodySettings, MelodySettingsUpdate
|
from settings.models import MelodySettings, MelodySettingsUpdate
|
||||||
|
from settings.public_features_models import PublicFeaturesSettings, PublicFeaturesSettingsUpdate
|
||||||
from settings import service
|
from settings import service
|
||||||
|
from settings import public_features_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||||
|
|
||||||
@@ -20,3 +23,20 @@ async def update_melody_settings(
|
|||||||
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
_user: TokenPayload = Depends(require_permission("melodies", "edit")),
|
||||||
):
|
):
|
||||||
return service.update_melody_settings(body)
|
return service.update_melody_settings(body)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Public Features Settings (sysadmin / admin only) ─────────────────────────
|
||||||
|
|
||||||
|
@router.get("/public-features", response_model=PublicFeaturesSettings)
|
||||||
|
async def get_public_features(
|
||||||
|
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
||||||
|
):
|
||||||
|
return public_features_service.get_public_features()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/public-features", response_model=PublicFeaturesSettings)
|
||||||
|
async def update_public_features(
|
||||||
|
body: PublicFeaturesSettingsUpdate,
|
||||||
|
_user: TokenPayload = Depends(require_roles(Role.sysadmin, Role.admin)),
|
||||||
|
):
|
||||||
|
return public_features_service.update_public_features(body)
|
||||||
|
|||||||
@@ -177,16 +177,16 @@ def _build_page(entries: List[bytes], slot_counts: List[int], seq: int = 0) -> b
|
|||||||
return page
|
return page
|
||||||
|
|
||||||
|
|
||||||
def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
|
def generate(serial_number: str, hw_family: str, hw_revision: str) -> bytes:
|
||||||
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
"""Generate a 0x5000-byte NVS partition binary for a Vesper device.
|
||||||
|
|
||||||
serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA'
|
serial_number: full SN string e.g. 'BSVSPR-26C13X-STD01R-X7KQA'
|
||||||
hw_type: board family e.g. 'vesper', 'vesper_plus', 'vesper_pro'
|
hw_family: board family e.g. 'vesper-standard', 'vesper-plus'
|
||||||
hw_version: zero-padded revision e.g. '01'
|
hw_revision: hardware revision string e.g. '1.0'
|
||||||
|
|
||||||
Writes the NEW schema keys (2.0+) expected by ConfigManager:
|
Writes the schema keys expected by ConfigManager (struct DeviceConfig):
|
||||||
serial ← full serial number
|
serial ← full serial number
|
||||||
hw_family ← board family (hw_type value, lowercase)
|
hw_family ← board family (lowercase)
|
||||||
hw_revision ← hardware revision string
|
hw_revision ← hardware revision string
|
||||||
|
|
||||||
Returns raw bytes ready to flash at 0x9000.
|
Returns raw bytes ready to flash at 0x9000.
|
||||||
@@ -196,8 +196,8 @@ def generate(serial_number: str, hw_type: str, hw_version: str) -> bytes:
|
|||||||
# Build entries for namespace "device_id"
|
# Build entries for namespace "device_id"
|
||||||
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
|
ns_entry, ns_span = _build_namespace_entry("device_id", ns_index)
|
||||||
uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number)
|
uid_entry, uid_span = _build_string_entry(ns_index, "serial", serial_number)
|
||||||
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_type.lower())
|
hwt_entry, hwt_span = _build_string_entry(ns_index, "hw_family", hw_family.lower())
|
||||||
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_version)
|
hwv_entry, hwv_span = _build_string_entry(ns_index, "hw_revision", hw_revision)
|
||||||
|
|
||||||
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
|
entries = [ns_entry, uid_entry, hwt_entry, hwv_entry]
|
||||||
spans = [ns_span, uid_span, hwt_span, hwv_span]
|
spans = [ns_span, uid_span, hwt_span, hwv_span]
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { Routes, Route, Navigate } from "react-router-dom";
|
import { Routes, Route, Navigate } from "react-router-dom";
|
||||||
import { useAuth } from "./auth/AuthContext";
|
import { useAuth } from "./auth/AuthContext";
|
||||||
|
import CloudFlashPage from "./cloudflash/CloudFlashPage";
|
||||||
|
import PublicFeaturesSettings from "./settings/PublicFeaturesSettings";
|
||||||
import LoginPage from "./auth/LoginPage";
|
import LoginPage from "./auth/LoginPage";
|
||||||
import MainLayout from "./layout/MainLayout";
|
import MainLayout from "./layout/MainLayout";
|
||||||
import MelodyList from "./melodies/MelodyList";
|
import MelodyList from "./melodies/MelodyList";
|
||||||
@@ -106,6 +108,9 @@ function RoleGate({ roles, children }) {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Public routes — no login required */}
|
||||||
|
<Route path="/cloudflash" element={<CloudFlashPage />} />
|
||||||
|
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route
|
<Route
|
||||||
element={
|
element={
|
||||||
@@ -188,6 +193,9 @@ export default function App() {
|
|||||||
<Route path="settings/staff/:id" element={<RoleGate roles={["sysadmin", "admin"]}><StaffDetail /></RoleGate>} />
|
<Route path="settings/staff/:id" element={<RoleGate roles={["sysadmin", "admin"]}><StaffDetail /></RoleGate>} />
|
||||||
<Route path="settings/staff/:id/edit" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
<Route path="settings/staff/:id/edit" element={<RoleGate roles={["sysadmin", "admin"]}><StaffForm /></RoleGate>} />
|
||||||
|
|
||||||
|
{/* Settings - Public Features */}
|
||||||
|
<Route path="settings/public-features" element={<RoleGate roles={["sysadmin", "admin"]}><PublicFeaturesSettings /></RoleGate>} />
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to="/" replace />} />
|
<Route path="*" element={<Navigate to="/" replace />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
BIN
frontend/src/assets/logos/cloudflash_large.png
Normal file
BIN
frontend/src/assets/logos/cloudflash_large.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 103 KiB |
BIN
frontend/src/assets/logos/cloudflash_small.png
Normal file
BIN
frontend/src/assets/logos/cloudflash_small.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
1053
frontend/src/cloudflash/CloudFlashPage.jsx
Normal file
1053
frontend/src/cloudflash/CloudFlashPage.jsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1188,7 +1188,7 @@ export default function CustomerDetail() {
|
|||||||
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
{customer.notes.map((note, i) => (
|
{customer.notes.map((note, i) => (
|
||||||
<div key={i} className="px-3 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--bg-primary)" }}>
|
<div key={i} className="px-3 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--bg-primary)" }}>
|
||||||
<p style={{ color: "var(--text-primary)" }}>{note.text}</p>
|
<p style={{ color: "var(--text-primary)", whiteSpace: "pre-wrap" }}>{note.text}</p>
|
||||||
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
|
{note.by} · {note.at ? new Date(note.at).toLocaleDateString() : ""}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1556,6 +1556,7 @@ const TAB_DEFS = [
|
|||||||
{ id: "bells", label: "Bell Mechanisms", tone: "bells" },
|
{ id: "bells", label: "Bell Mechanisms", tone: "bells" },
|
||||||
{ id: "clock", label: "Clock & Alerts", tone: "clock" },
|
{ id: "clock", label: "Clock & Alerts", tone: "clock" },
|
||||||
{ id: "warranty", label: "Warranty & Subscription", tone: "warranty" },
|
{ id: "warranty", label: "Warranty & Subscription", tone: "warranty" },
|
||||||
|
{ id: "manage", label: "Manage", tone: "manage" },
|
||||||
{ id: "control", label: "Control", tone: "control" },
|
{ id: "control", label: "Control", tone: "control" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -1574,6 +1575,104 @@ function calcMaintenanceProgress(lastDate, periodDays) {
|
|||||||
return Math.max(0, Math.min(100, (elapsed / total) * 100));
|
return Math.max(0, Math.min(100, (elapsed / total) * 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Customer Assign Modal ────────────────────────────────────────────────────
|
||||||
|
function CustomerAssignModal({ deviceId, onSelect, onCancel }) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const inputRef = useRef(null);
|
||||||
|
|
||||||
|
useEffect(() => { inputRef.current?.focus(); }, []);
|
||||||
|
|
||||||
|
const search = useCallback(async (q) => {
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/devices/${deviceId}/customer-search?q=${encodeURIComponent(q)}`);
|
||||||
|
setResults(data.results || []);
|
||||||
|
} catch {
|
||||||
|
setResults([]);
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, [deviceId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => search(query), 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [query, search]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border w-full max-w-lg flex flex-col"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "80vh" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between px-6 pt-5 pb-4 border-b" style={{ borderColor: "var(--border-secondary)" }}>
|
||||||
|
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Assign to Customer</h3>
|
||||||
|
<button type="button" onClick={onCancel} className="text-lg leading-none hover:opacity-70 cursor-pointer" style={{ color: "var(--text-muted)" }}>✕</button>
|
||||||
|
</div>
|
||||||
|
<div className="px-6 pt-4 pb-2">
|
||||||
|
<div style={{ position: "relative" }}>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="Search by name, email, phone, org, tags…"
|
||||||
|
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>
|
||||||
|
<div className="overflow-y-auto flex-1 px-6 pb-4">
|
||||||
|
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-secondary)", minHeight: 60 }}>
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{searching ? "Searching…" : query ? "No customers found." : "Type to search customers…"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
results.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onSelect(c)}
|
||||||
|
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 block">
|
||||||
|
{[c.name, c.surname].filter(Boolean).join(" ")}
|
||||||
|
{c.city && (
|
||||||
|
<>
|
||||||
|
<span className="mx-1.5" style={{ color: "var(--text-muted)", fontSize: "8px", verticalAlign: "middle" }}>●</span>
|
||||||
|
<span style={{ color: "var(--text-muted)", fontWeight: 400 }}>{c.city}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{c.organization && (
|
||||||
|
<span className="text-xs block" style={{ color: "var(--text-muted)" }}>{c.organization}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-secondary)" }}>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function DeviceDetail() {
|
export default function DeviceDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -1630,18 +1729,29 @@ export default function DeviceDetail() {
|
|||||||
const d = await api.get(`/devices/${id}`);
|
const d = await api.get(`/devices/${id}`);
|
||||||
setDevice(d);
|
setDevice(d);
|
||||||
if (d.staffNotes) setStaffNotes(d.staffNotes);
|
if (d.staffNotes) setStaffNotes(d.staffNotes);
|
||||||
|
if (Array.isArray(d.tags)) setTags(d.tags);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
// Phase 2: fire async background fetches — do not block the render
|
// Phase 2: fire async background fetches — do not block the render
|
||||||
if (d.device_id) {
|
const deviceSN = d.serial_number || d.device_id;
|
||||||
|
if (deviceSN) {
|
||||||
api.get("/mqtt/status").then((mqttData) => {
|
api.get("/mqtt/status").then((mqttData) => {
|
||||||
if (mqttData?.devices) {
|
if (mqttData?.devices) {
|
||||||
const match = mqttData.devices.find((s) => s.device_serial === d.device_id);
|
const match = mqttData.devices.find((s) => s.device_serial === deviceSN);
|
||||||
setMqttStatus(match || null);
|
setMqttStatus(match || null);
|
||||||
}
|
}
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch owner customer details
|
||||||
|
if (d.customer_id) {
|
||||||
|
api.get(`/devices/${id}/customer`).then((res) => {
|
||||||
|
setOwnerCustomer(res.customer || null);
|
||||||
|
}).catch(() => setOwnerCustomer(null));
|
||||||
|
} else {
|
||||||
|
setOwnerCustomer(null);
|
||||||
|
}
|
||||||
|
|
||||||
setUsersLoading(true);
|
setUsersLoading(true);
|
||||||
api.get(`/devices/${id}/users`).then((data) => {
|
api.get(`/devices/${id}/users`).then((data) => {
|
||||||
setDeviceUsers(data.users || []);
|
setDeviceUsers(data.users || []);
|
||||||
@@ -1650,9 +1760,9 @@ export default function DeviceDetail() {
|
|||||||
}).finally(() => setUsersLoading(false));
|
}).finally(() => setUsersLoading(false));
|
||||||
|
|
||||||
// Fetch manufacturing record + product catalog to resolve hw image
|
// Fetch manufacturing record + product catalog to resolve hw image
|
||||||
if (d.device_id) {
|
if (deviceSN) {
|
||||||
Promise.all([
|
Promise.all([
|
||||||
api.get(`/manufacturing/devices/${d.device_id}`).catch(() => null),
|
api.get(`/manufacturing/devices/${deviceSN}`).catch(() => null),
|
||||||
api.get("/crm/products").catch(() => null),
|
api.get("/crm/products").catch(() => null),
|
||||||
]).then(([mfgItem, productsRes]) => {
|
]).then(([mfgItem, productsRes]) => {
|
||||||
const hwType = mfgItem?.hw_type || "";
|
const hwType = mfgItem?.hw_type || "";
|
||||||
@@ -1719,6 +1829,85 @@ export default function DeviceDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- Device Notes handlers ---
|
||||||
|
const handleAddNote = async () => {
|
||||||
|
if (!newNoteText.trim()) return;
|
||||||
|
setSavingNote(true);
|
||||||
|
try {
|
||||||
|
const data = await api.post(`/devices/${id}/notes`, {
|
||||||
|
content: newNoteText.trim(),
|
||||||
|
created_by: "admin",
|
||||||
|
});
|
||||||
|
setDeviceNotes((prev) => [data, ...prev]);
|
||||||
|
setNewNoteText("");
|
||||||
|
setAddingNote(false);
|
||||||
|
} catch {} finally { setSavingNote(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateNote = async (noteId) => {
|
||||||
|
if (!editingNoteText.trim()) return;
|
||||||
|
setSavingNote(true);
|
||||||
|
try {
|
||||||
|
const data = await api.put(`/devices/${id}/notes/${noteId}`, { content: editingNoteText.trim() });
|
||||||
|
setDeviceNotes((prev) => prev.map((n) => n.id === noteId ? data : n));
|
||||||
|
setEditingNoteId(null);
|
||||||
|
} catch {} finally { setSavingNote(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteNote = async (noteId) => {
|
||||||
|
if (!window.confirm("Delete this note?")) return;
|
||||||
|
try {
|
||||||
|
await api.delete(`/devices/${id}/notes/${noteId}`);
|
||||||
|
setDeviceNotes((prev) => prev.filter((n) => n.id !== noteId));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Tags handlers ---
|
||||||
|
const handleAddTag = async (tag) => {
|
||||||
|
const trimmed = tag.trim();
|
||||||
|
if (!trimmed || tags.includes(trimmed)) return;
|
||||||
|
const next = [...tags, trimmed];
|
||||||
|
setSavingTags(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/devices/${id}/tags`, { tags: next });
|
||||||
|
setTags(next);
|
||||||
|
setTagInput("");
|
||||||
|
} catch {} finally { setSavingTags(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveTag = async (tag) => {
|
||||||
|
const next = tags.filter((t) => t !== tag);
|
||||||
|
setSavingTags(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/devices/${id}/tags`, { tags: next });
|
||||||
|
setTags(next);
|
||||||
|
} catch {} finally { setSavingTags(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
// --- Customer assign handlers ---
|
||||||
|
const handleAssignCustomer = async (customer) => {
|
||||||
|
setAssigningCustomer(true);
|
||||||
|
try {
|
||||||
|
await api.post(`/devices/${id}/assign-customer`, { customer_id: customer.id });
|
||||||
|
setDevice((prev) => ({ ...prev, customer_id: customer.id }));
|
||||||
|
setOwnerCustomer(customer);
|
||||||
|
setShowAssignSearch(false);
|
||||||
|
setCustomerSearch("");
|
||||||
|
setCustomerResults([]);
|
||||||
|
} catch {} finally { setAssigningCustomer(false); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUnassignCustomer = async () => {
|
||||||
|
if (!window.confirm("Remove customer assignment?")) return;
|
||||||
|
setAssigningCustomer(true);
|
||||||
|
try {
|
||||||
|
const cid = device?.customer_id;
|
||||||
|
await api.delete(`/devices/${id}/assign-customer${cid ? `?customer_id=${cid}` : ""}`);
|
||||||
|
setDevice((prev) => ({ ...prev, customer_id: "" }));
|
||||||
|
setOwnerCustomer(null);
|
||||||
|
} catch {} finally { setAssigningCustomer(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const requestStrikeCounters = useCallback(async (force = false) => {
|
const requestStrikeCounters = useCallback(async (force = false) => {
|
||||||
if (!device?.device_id) return;
|
if (!device?.device_id) return;
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -1800,6 +1989,66 @@ export default function DeviceDetail() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [ctrlCmdAutoRefresh, device?.device_id, fetchCtrlCmdHistory]);
|
}, [ctrlCmdAutoRefresh, device?.device_id, fetchCtrlCmdHistory]);
|
||||||
|
|
||||||
|
// --- Device Notes state (MUST be before early returns) ---
|
||||||
|
const [deviceNotes, setDeviceNotes] = useState([]);
|
||||||
|
const [notesLoaded, setNotesLoaded] = useState(false);
|
||||||
|
const [addingNote, setAddingNote] = useState(false);
|
||||||
|
const [newNoteText, setNewNoteText] = useState("");
|
||||||
|
const [savingNote, setSavingNote] = useState(false);
|
||||||
|
const [editingNoteId, setEditingNoteId] = useState(null);
|
||||||
|
const [editingNoteText, setEditingNoteText] = useState("");
|
||||||
|
|
||||||
|
const loadDeviceNotes = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/devices/${id}/notes`);
|
||||||
|
setDeviceNotes(data.notes || []);
|
||||||
|
setNotesLoaded(true);
|
||||||
|
} catch {
|
||||||
|
setNotesLoaded(true);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) loadDeviceNotes();
|
||||||
|
}, [id, loadDeviceNotes]);
|
||||||
|
|
||||||
|
// --- Tags state (MUST be before early returns) ---
|
||||||
|
const [tags, setTags] = useState([]);
|
||||||
|
const [tagInput, setTagInput] = useState("");
|
||||||
|
const [savingTags, setSavingTags] = useState(false);
|
||||||
|
|
||||||
|
// --- Customer assign state (MUST be before early returns) ---
|
||||||
|
const [assigningCustomer, setAssigningCustomer] = useState(false);
|
||||||
|
const [showAssignSearch, setShowAssignSearch] = useState(false);
|
||||||
|
const [ownerCustomer, setOwnerCustomer] = useState(null);
|
||||||
|
|
||||||
|
// --- User assignment state (MUST be before early returns) ---
|
||||||
|
const [showUserSearch, setShowUserSearch] = useState(false);
|
||||||
|
const [userSearchQuery, setUserSearchQuery] = useState("");
|
||||||
|
const [userSearchResults, setUserSearchResults] = useState([]);
|
||||||
|
const [userSearching, setUserSearching] = useState(false);
|
||||||
|
const [addingUser, setAddingUser] = useState(null);
|
||||||
|
const [removingUser, setRemovingUser] = useState(null);
|
||||||
|
const userSearchInputRef = useRef(null);
|
||||||
|
|
||||||
|
const searchUsers = useCallback(async (q) => {
|
||||||
|
setUserSearching(true);
|
||||||
|
try {
|
||||||
|
const data = await api.get(`/devices/${id}/user-search?q=${encodeURIComponent(q)}`);
|
||||||
|
setUserSearchResults(data.results || []);
|
||||||
|
} catch {
|
||||||
|
setUserSearchResults([]);
|
||||||
|
} finally {
|
||||||
|
setUserSearching(false);
|
||||||
|
}
|
||||||
|
}, [id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showUserSearch) return;
|
||||||
|
const t = setTimeout(() => searchUsers(userSearchQuery), 250);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [userSearchQuery, searchUsers, showUserSearch]);
|
||||||
|
|
||||||
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
if (loading) return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
if (error) return (
|
if (error) return (
|
||||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
@@ -1952,7 +2201,7 @@ 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.device_id || "-"}</span>
|
<span className="db-info-value">{device.serial_number || device.device_id || "-"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="db-row">
|
<div className="db-row">
|
||||||
@@ -2115,7 +2364,7 @@ export default function DeviceDetail() {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DeviceLogsPanel deviceSerial={device.device_id} />
|
<DeviceLogsPanel deviceSerial={device.serial_number || device.device_id} />
|
||||||
|
|
||||||
<div className="dashboard-bottom-grid">
|
<div className="dashboard-bottom-grid">
|
||||||
<div className="dashboard-bottom-grid__notes" ref={notesPanelRef}>
|
<div className="dashboard-bottom-grid__notes" ref={notesPanelRef}>
|
||||||
@@ -2253,6 +2502,260 @@ export default function DeviceDetail() {
|
|||||||
<EmptyCell />
|
<EmptyCell />
|
||||||
</FieldRow>
|
</FieldRow>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* ── Tags ── */}
|
||||||
|
<SectionCard title="Tags">
|
||||||
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>No tags yet.</span>
|
||||||
|
)}
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||||
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)", border: "1px solid var(--badge-blue-text)" }}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRemoveTag(tag)}
|
||||||
|
disabled={savingTags}
|
||||||
|
className="ml-0.5 hover:opacity-70 cursor-pointer disabled:opacity-40"
|
||||||
|
style={{ lineHeight: 1 }}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={tagInput}
|
||||||
|
onChange={(e) => setTagInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(tagInput); } }}
|
||||||
|
placeholder="Add tag and press Enter…"
|
||||||
|
className="px-3 py-1.5 rounded-md text-sm border flex-1"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleAddTag(tagInput)}
|
||||||
|
disabled={!tagInput.trim() || savingTags}
|
||||||
|
className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50 cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* ── Owner ── */}
|
||||||
|
<SectionCard title="Owner">
|
||||||
|
{device.customer_id ? (
|
||||||
|
<div>
|
||||||
|
{ownerCustomer ? (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-3 p-3 rounded-md border mb-3 cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
|
||||||
|
onClick={() => navigate(`/crm/customers/${device.customer_id}`)}
|
||||||
|
title="View customer"
|
||||||
|
>
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||||
|
{(ownerCustomer.name || "?")[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{ownerCustomer.name || "—"}</p>
|
||||||
|
{ownerCustomer.organization && (
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>{ownerCustomer.organization}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<svg className="w-4 h-4 shrink-0" 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>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>Customer assigned (loading details…)</p>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAssignSearch(true)}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Reassign
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleUnassignCustomer}
|
||||||
|
disabled={assigningCustomer}
|
||||||
|
className="text-xs px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50 hover:opacity-80"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)", border: "1px solid var(--danger)" }}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<p className="text-sm mb-3" style={{ color: "var(--text-muted)" }}>No customer assigned yet.</p>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowAssignSearch(true)}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-md cursor-pointer hover:opacity-80"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Assign to Customer
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAssignSearch && (
|
||||||
|
<CustomerAssignModal
|
||||||
|
deviceId={id}
|
||||||
|
onSelect={(c) => { setShowAssignSearch(false); handleAssignCustomer(c); }}
|
||||||
|
onCancel={() => setShowAssignSearch(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
{/* ── Device Notes ── */}
|
||||||
|
<SectionCard title="Device Notes">
|
||||||
|
{!notesLoaded ? (
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>Loading…</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{deviceNotes.length === 0 && !addingNote && (
|
||||||
|
<p className="text-xs mb-3" style={{ color: "var(--text-muted)" }}>No notes for this device.</p>
|
||||||
|
)}
|
||||||
|
<div className="space-y-3 mb-3">
|
||||||
|
{deviceNotes.map((note) => (
|
||||||
|
<div
|
||||||
|
key={note.id}
|
||||||
|
className="p-3 rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
{editingNoteId === note.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={editingNoteText}
|
||||||
|
onChange={(e) => setEditingNoteText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-2 py-1.5 rounded text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)", resize: "vertical" }}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateNote(note.id)}
|
||||||
|
disabled={savingNote}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-md cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{savingNote ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditingNoteId(null)}
|
||||||
|
className="text-xs px-2.5 py-1 rounded-md cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm whitespace-pre-wrap" style={{ color: "var(--text-secondary)" }}>{note.content}</p>
|
||||||
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{note.created_by}{note.created_at ? ` · ${new Date(note.created_at).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" })}` : ""}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-1.5 shrink-0">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setEditingNoteId(note.id); setEditingNoteText(note.content); }}
|
||||||
|
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
|
||||||
|
style={{ color: "var(--text-muted)" }}
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleDeleteNote(note.id)}
|
||||||
|
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80"
|
||||||
|
style={{ color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
addingNote ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
value={newNoteText}
|
||||||
|
onChange={(e) => setNewNoteText(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
placeholder="Write a note…"
|
||||||
|
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" }}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleAddNote}
|
||||||
|
disabled={savingNote || !newNoteText.trim()}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-md cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{savingNote ? "Saving…" : "Add Note"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setAddingNote(false); setNewNoteText(""); }}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setAddingNote(true)}
|
||||||
|
className="text-sm px-3 py-1.5 rounded-md cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
+ Add Note
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3039,12 +3542,182 @@ export default function DeviceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Manage tab ──────────────────────────────────────────────────────────────
|
||||||
|
const manageTab = (
|
||||||
|
<div className="device-tab-stack">
|
||||||
|
{/* Issues & Notes — full width */}
|
||||||
|
<NotesPanel key={`manage-${id}`} deviceId={id} />
|
||||||
|
|
||||||
|
{/* User Assignment */}
|
||||||
|
<section className="rounded-lg border p-6 mt-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
App Users ({deviceUsers.length})
|
||||||
|
</h2>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { setShowUserSearch(true); setUserSearchQuery(""); setUserSearchResults([]); setTimeout(() => userSearchInputRef.current?.focus(), 50); }}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-md cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
+ Add User
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usersLoading ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users…</p>
|
||||||
|
) : deviceUsers.length === 0 ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned. Users are added when they claim the device via the app, or you can add them manually.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{deviceUsers.map((u) => (
|
||||||
|
<div
|
||||||
|
key={u.user_id}
|
||||||
|
className="flex items-center gap-3 px-3 py-2.5 rounded-md border"
|
||||||
|
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-secondary)" }}
|
||||||
|
>
|
||||||
|
{u.photo_url ? (
|
||||||
|
<img src={u.photo_url} alt="" className="w-8 h-8 rounded-full object-cover shrink-0" />
|
||||||
|
) : (
|
||||||
|
<div className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||||
|
{(u.display_name || u.email || "?")[0].toUpperCase()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium" style={{ color: "var(--text-primary)" }}>{u.display_name || "—"}</p>
|
||||||
|
{u.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
|
||||||
|
</div>
|
||||||
|
{u.role && (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full shrink-0"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
|
{u.role}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={removingUser === u.user_id}
|
||||||
|
onClick={async () => {
|
||||||
|
setRemovingUser(u.user_id);
|
||||||
|
try {
|
||||||
|
await api.delete(`/devices/${id}/user-list/${u.user_id}`);
|
||||||
|
setDeviceUsers((prev) => prev.filter((x) => x.user_id !== u.user_id));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setRemovingUser(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs px-2 py-0.5 rounded cursor-pointer hover:opacity-80 disabled:opacity-40 shrink-0"
|
||||||
|
style={{ color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{removingUser === u.user_id ? "…" : "Remove"}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User search modal */}
|
||||||
|
{showUserSearch && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||||
|
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="rounded-xl border p-6 w-full max-w-md"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-bold mb-4" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Add User
|
||||||
|
</h2>
|
||||||
|
<div style={{ position: "relative" }} className="mb-3">
|
||||||
|
<input
|
||||||
|
ref={userSearchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={userSearchQuery}
|
||||||
|
onChange={(e) => setUserSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search by name, email, or phone…"
|
||||||
|
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)" }}
|
||||||
|
/>
|
||||||
|
{userSearching && (
|
||||||
|
<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: 260, minHeight: 48 }}
|
||||||
|
>
|
||||||
|
{userSearchResults.length === 0 ? (
|
||||||
|
<p className="px-3 py-3 text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{userSearching ? "Searching…" : userSearchQuery ? "No users found." : "Type to search users…"}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
userSearchResults.map((u) => {
|
||||||
|
const alreadyAdded = deviceUsers.some((du) => du.user_id === u.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={u.id}
|
||||||
|
type="button"
|
||||||
|
disabled={alreadyAdded || addingUser === u.id}
|
||||||
|
onClick={async () => {
|
||||||
|
setAddingUser(u.id);
|
||||||
|
try {
|
||||||
|
await api.post(`/devices/${id}/user-list`, { user_id: u.id });
|
||||||
|
setDeviceUsers((prev) => [...prev, {
|
||||||
|
user_id: u.id,
|
||||||
|
display_name: u.display_name,
|
||||||
|
email: u.email,
|
||||||
|
photo_url: u.photo_url,
|
||||||
|
role: "",
|
||||||
|
}]);
|
||||||
|
setShowUserSearch(false);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message);
|
||||||
|
} finally {
|
||||||
|
setAddingUser(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2.5 text-sm border-b last:border-b-0 transition-colors cursor-pointer disabled:opacity-50"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-secondary)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
<span className="font-medium">{u.display_name || u.email || u.id}</span>
|
||||||
|
{u.email && <span className="block text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</span>}
|
||||||
|
{alreadyAdded && <span className="ml-2 text-xs" style={{ color: "var(--success-text)" }}>Already added</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowUserSearch(false)}
|
||||||
|
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>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
const renderTabContent = () => {
|
const renderTabContent = () => {
|
||||||
if (activeTab === "dashboard") return dashboardTab;
|
if (activeTab === "dashboard") return dashboardTab;
|
||||||
if (activeTab === "general") return generalInfoTab;
|
if (activeTab === "general") return generalInfoTab;
|
||||||
if (activeTab === "bells") return bellMechanismsTab;
|
if (activeTab === "bells") return bellMechanismsTab;
|
||||||
if (activeTab === "clock") return clockAlertsTab;
|
if (activeTab === "clock") return clockAlertsTab;
|
||||||
if (activeTab === "warranty") return warrantySubscriptionTab;
|
if (activeTab === "warranty") return warrantySubscriptionTab;
|
||||||
|
if (activeTab === "manage") return manageTab;
|
||||||
return controlTab;
|
return controlTab;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3136,7 +3809,7 @@ export default function DeviceDetail() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={showDelete}
|
open={showDelete}
|
||||||
title="Delete Device"
|
title="Delete Device"
|
||||||
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.device_id})? This action cannot be undone.`}
|
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.serial_number || device.device_id})? This action cannot be undone.`}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setShowDelete(false)}
|
onCancel={() => setShowDelete(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "warrantyActive", label: "Warranty Active", defaultOn: false },
|
{ key: "warrantyActive", label: "Warranty Active", defaultOn: false },
|
||||||
{ key: "totalMelodies", label: "Total Melodies", defaultOn: false },
|
{ key: "totalMelodies", label: "Total Melodies", defaultOn: false },
|
||||||
{ key: "assignedUsers", label: "Assigned Users", defaultOn: true },
|
{ key: "assignedUsers", label: "Assigned Users", defaultOn: true },
|
||||||
|
{ key: "tags", label: "Tags", defaultOn: false },
|
||||||
|
{ key: "hw_family", label: "HW Family", defaultOn: false },
|
||||||
|
{ key: "hw_revision", label: "HW Revision", defaultOn: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
function getDefaultVisibleColumns() {
|
function getDefaultVisibleColumns() {
|
||||||
@@ -199,7 +202,8 @@ export default function DeviceList() {
|
|||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "status": {
|
case "status": {
|
||||||
const mqtt = mqttStatusMap[device.device_id];
|
const sn = device.serial_number || device.device_id;
|
||||||
|
const mqtt = mqttStatusMap[sn];
|
||||||
const isOnline = mqtt ? mqtt.online : device.is_Online;
|
const isOnline = mqtt ? mqtt.online : device.is_Online;
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
@@ -216,7 +220,7 @@ export default function DeviceList() {
|
|||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case "serialNumber":
|
case "serialNumber":
|
||||||
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.device_id || "-"}</span>;
|
return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.serial_number || device.device_id || "-"}</span>;
|
||||||
case "location":
|
case "location":
|
||||||
return device.device_location || "-";
|
return device.device_location || "-";
|
||||||
case "subscrTier":
|
case "subscrTier":
|
||||||
@@ -260,8 +264,31 @@ export default function DeviceList() {
|
|||||||
return <BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />;
|
return <BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />;
|
||||||
case "totalMelodies":
|
case "totalMelodies":
|
||||||
return device.device_melodies_all?.length ?? 0;
|
return device.device_melodies_all?.length ?? 0;
|
||||||
case "assignedUsers":
|
case "assignedUsers": {
|
||||||
return device.user_list?.length ?? 0;
|
const ul = Array.isArray(device.user_list) ? device.user_list : [];
|
||||||
|
return ul.length;
|
||||||
|
}
|
||||||
|
case "tags": {
|
||||||
|
const tagList = Array.isArray(device.tags) ? device.tags : [];
|
||||||
|
if (tagList.length === 0) return <span style={{ color: "var(--text-muted)" }}>—</span>;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{tagList.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="px-1.5 py-0.5 text-xs rounded-full"
|
||||||
|
style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}
|
||||||
|
>
|
||||||
|
{t}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "hw_family":
|
||||||
|
return device.hw_family || <span style={{ color: "var(--text-muted)" }}>—</span>;
|
||||||
|
case "hw_revision":
|
||||||
|
return device.hw_revision || <span style={{ color: "var(--text-muted)" }}>—</span>;
|
||||||
default:
|
default:
|
||||||
return "-";
|
return "-";
|
||||||
}
|
}
|
||||||
@@ -566,7 +593,7 @@ export default function DeviceList() {
|
|||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={!!deleteTarget}
|
open={!!deleteTarget}
|
||||||
title="Delete Device"
|
title="Delete Device"
|
||||||
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.device_id || ""})? This action cannot be undone.`}
|
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.serial_number || deleteTarget?.device_id || ""})? This action cannot be undone.`}
|
||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -146,6 +146,7 @@ const navSections = [
|
|||||||
// Bottom settings — always rendered separately at the bottom
|
// Bottom settings — always rendered separately at the bottom
|
||||||
const settingsChildren = [
|
const settingsChildren = [
|
||||||
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
|
{ to: "/settings/staff", label: "Staff Management", icon: StaffIcon },
|
||||||
|
{ to: "/settings/public-features", label: "Public Features", icon: SettingsIcon },
|
||||||
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
|
{ to: "/settings/pages", label: "Page Settings", icon: PlaceholderIcon, placeholder: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
@@ -7,13 +8,13 @@ import api from "../api/client";
|
|||||||
|
|
||||||
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos
|
||||||
const BOARD_TYPES = [
|
const BOARD_TYPES = [
|
||||||
{ value: "vesper_pro", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" },
|
{ value: "vesper_pro", name: "Vesper Pro", codename: "vesper-pro", family: "vesper" },
|
||||||
{ value: "vesper_plus", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" },
|
{ value: "vesper_plus", name: "Vesper+", codename: "vesper-plus", family: "vesper" },
|
||||||
{ value: "vesper", name: "VESPER", codename: "vesper-basic", family: "vesper" },
|
{ value: "vesper", name: "Vesper", codename: "vesper-basic", family: "vesper" },
|
||||||
{ value: "agnus", name: "AGNUS", codename: "agnus-basic", family: "agnus" },
|
{ value: "agnus", name: "Agnus", codename: "agnus-basic", family: "agnus" },
|
||||||
{ value: "agnus_mini", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" },
|
{ value: "agnus_mini", name: "Agnus Mini", codename: "agnus-mini", family: "agnus" },
|
||||||
{ value: "chronos_pro", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" },
|
{ value: "chronos_pro", name: "Chronos Pro", codename: "chronos-pro", family: "chronos" },
|
||||||
{ value: "chronos", name: "CHRONOS", codename: "chronos-basic", family: "chronos" },
|
{ value: "chronos", name: "Chronos", codename: "chronos-basic", family: "chronos" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const BOARD_FAMILY_COLORS = {
|
const BOARD_FAMILY_COLORS = {
|
||||||
@@ -22,7 +23,11 @@ const BOARD_FAMILY_COLORS = {
|
|||||||
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
|
chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const BOARD_TYPE_LABELS = Object.fromEntries(BOARD_TYPES.map((b) => [b.value, b.name]));
|
const BOARD_TYPE_LABELS = {
|
||||||
|
vesper: "Vesper", vesper_plus: "Vesper+", vesper_pro: "Vesper Pro",
|
||||||
|
agnus: "Agnus", agnus_mini: "Agnus Mini",
|
||||||
|
chronos: "Chronos", chronos_pro: "Chronos Pro",
|
||||||
|
};
|
||||||
|
|
||||||
const STATUS_STYLES = {
|
const STATUS_STYLES = {
|
||||||
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
manufactured: { bg: "var(--bg-card-hover)", color: "var(--text-muted)" },
|
||||||
@@ -44,7 +49,8 @@ const ALL_COLUMNS = [
|
|||||||
{ id: "status", label: "Status", default: true },
|
{ id: "status", label: "Status", default: true },
|
||||||
{ id: "batch", label: "Batch", default: true },
|
{ id: "batch", label: "Batch", default: true },
|
||||||
{ id: "created", label: "Created", default: true },
|
{ id: "created", label: "Created", default: true },
|
||||||
{ id: "owner", label: "Owner", default: true },
|
{ id: "owner", label: "Customer", default: true },
|
||||||
|
{ id: "users", label: "Device Users", default: true },
|
||||||
{ id: "name", label: "Device Name", default: false },
|
{ id: "name", label: "Device Name", default: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -89,6 +95,80 @@ function StatusBadge({ status }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UsersHoverCell({ userList, resolvedUsersMap }) {
|
||||||
|
const [popupPos, setPopupPos] = useState(null);
|
||||||
|
const triggerRef = useRef(null);
|
||||||
|
const count = userList.length;
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
if (!triggerRef.current) return;
|
||||||
|
const rect = triggerRef.current.getBoundingClientRect();
|
||||||
|
setPopupPos({ top: rect.top + window.scrollY, left: rect.left + window.scrollX });
|
||||||
|
};
|
||||||
|
const handleMouseLeave = () => setPopupPos(null);
|
||||||
|
|
||||||
|
if (count === 0) return <span className="text-xs" style={{ color: "var(--text-muted)" }}>—</span>;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
ref={triggerRef}
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full font-medium cursor-default"
|
||||||
|
style={{ backgroundColor: "#431407", color: "#fb923c" }}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
onMouseLeave={handleMouseLeave}
|
||||||
|
>
|
||||||
|
{count} user{count !== 1 ? "s" : ""}
|
||||||
|
</span>
|
||||||
|
{popupPos && typeof document !== "undefined" &&
|
||||||
|
ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
onMouseEnter={() => setPopupPos(popupPos)}
|
||||||
|
onMouseLeave={() => setPopupPos(null)}
|
||||||
|
className="rounded-lg border shadow-xl p-3"
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: popupPos.top - 8,
|
||||||
|
left: popupPos.left,
|
||||||
|
transform: "translateY(-100%)",
|
||||||
|
zIndex: 9999,
|
||||||
|
minWidth: 220, maxWidth: 300,
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<p className="text-xs font-semibold mb-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Assigned Users
|
||||||
|
</p>
|
||||||
|
<div style={{ display: "flex", flexDirection: "column", gap: 8 }}>
|
||||||
|
{userList.map((uid) => {
|
||||||
|
const u = resolvedUsersMap[uid];
|
||||||
|
const initials = (u?.display_name || u?.email || uid)[0]?.toUpperCase() || "U";
|
||||||
|
return (
|
||||||
|
<div key={uid} style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||||
|
<div style={{
|
||||||
|
width: 26, height: 26, borderRadius: "50%", flexShrink: 0,
|
||||||
|
display: "flex", alignItems: "center", justifyContent: "center",
|
||||||
|
backgroundColor: "#431407", color: "#fb923c", fontSize: 10, fontWeight: 700,
|
||||||
|
}}>{initials}</div>
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<p className="text-xs font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{u?.display_name || uid}
|
||||||
|
</p>
|
||||||
|
{u?.email && <p className="text-xs" style={{ color: "var(--text-muted)" }}>{u.email}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function formatHwVersion(v) {
|
function formatHwVersion(v) {
|
||||||
if (!v) return "—";
|
if (!v) return "—";
|
||||||
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
|
if (/^\d+\.\d+/.test(v)) return `Rev ${v}`;
|
||||||
@@ -100,7 +180,7 @@ function formatHwVersion(v) {
|
|||||||
function formatDate(iso) {
|
function formatDate(iso) {
|
||||||
if (!iso) return "—";
|
if (!iso) return "—";
|
||||||
try {
|
try {
|
||||||
return new Date(iso).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
return new Date(iso).toLocaleDateString("en-GB", { day: "numeric", month: "short", year: "numeric" });
|
||||||
} catch { return iso; }
|
} catch { return iso; }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -676,6 +756,18 @@ export default function DeviceInventory() {
|
|||||||
// Column preferences
|
// Column preferences
|
||||||
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
||||||
|
|
||||||
|
// Pagination
|
||||||
|
const [pageSize, setPageSize] = useState(20);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
|
||||||
|
// Customer name lookup: { [customer_id]: { name, surname } }
|
||||||
|
const [customerMap, setCustomerMap] = useState({});
|
||||||
|
// User display lookup: { [uid]: { display_name, email } }
|
||||||
|
const [resolvedUsersMap, setResolvedUsersMap] = useState({});
|
||||||
|
|
||||||
|
// Hover state for checkbox reveal: rowId | null
|
||||||
|
const [hoveredRow, setHoveredRow] = useState(null);
|
||||||
|
|
||||||
// Selection
|
// Selection
|
||||||
const [selected, setSelected] = useState(new Set());
|
const [selected, setSelected] = useState(new Set());
|
||||||
const allIds = devices.map((d) => d.id);
|
const allIds = devices.map((d) => d.id);
|
||||||
@@ -695,12 +787,38 @@ export default function DeviceInventory() {
|
|||||||
if (search) params.set("search", search);
|
if (search) params.set("search", search);
|
||||||
if (statusFilter) params.set("status", statusFilter);
|
if (statusFilter) params.set("status", statusFilter);
|
||||||
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
|
if (hwTypeFilter) params.set("hw_type", hwTypeFilter);
|
||||||
params.set("limit", "200");
|
params.set("limit", "500");
|
||||||
const qs = params.toString();
|
const qs = params.toString();
|
||||||
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
|
const data = await api.get(`/manufacturing/devices${qs ? `?${qs}` : ""}`);
|
||||||
setDevices(data.devices);
|
const devs = data.devices || [];
|
||||||
// Clear selection on refresh
|
setDevices(devs);
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
|
|
||||||
|
// Fetch customer names for assigned devices
|
||||||
|
const customerIds = [...new Set(devs.map((d) => d.customer_id).filter(Boolean))];
|
||||||
|
if (customerIds.length) {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
customerIds.map((id) =>
|
||||||
|
api.get(`/manufacturing/customers/${id}`)
|
||||||
|
.then((c) => [id, c])
|
||||||
|
.catch(() => [id, { name: "", surname: "" }])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setCustomerMap(Object.fromEntries(entries));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch user display info for all user_list UIDs
|
||||||
|
const allUids = [...new Set(devs.flatMap((d) => d.user_list || []))];
|
||||||
|
if (allUids.length) {
|
||||||
|
const entries = await Promise.all(
|
||||||
|
allUids.map((uid) =>
|
||||||
|
api.get(`/users/${uid}`)
|
||||||
|
.then((u) => [uid, { display_name: u.display_name || "", email: u.email || "" }])
|
||||||
|
.catch(() => [uid, { display_name: "", email: "" }])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
setResolvedUsersMap(Object.fromEntries(entries));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -708,7 +826,7 @@ export default function DeviceInventory() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => { fetchDevices(); }, [search, statusFilter, hwTypeFilter]);
|
useEffect(() => { fetchDevices(); setPage(1); }, [search, statusFilter, hwTypeFilter]);
|
||||||
|
|
||||||
const updateColVisible = (id, visible) => {
|
const updateColVisible = (id, visible) => {
|
||||||
const next = { ...colPrefs.visible, [id]: visible };
|
const next = { ...colPrefs.visible, [id]: visible };
|
||||||
@@ -739,6 +857,12 @@ export default function DeviceInventory() {
|
|||||||
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
|
.map((id) => ALL_COLUMNS.find((c) => c.id === id))
|
||||||
.filter((c) => c && colPrefs.visible[c.id]);
|
.filter((c) => c && colPrefs.visible[c.id]);
|
||||||
|
|
||||||
|
const totalPages = pageSize > 0 ? Math.ceil(devices.length / pageSize) : 1;
|
||||||
|
const safePage = Math.min(page, Math.max(1, totalPages));
|
||||||
|
const pagedDevices = pageSize > 0
|
||||||
|
? devices.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||||||
|
: devices;
|
||||||
|
|
||||||
const renderCell = (col, device) => {
|
const renderCell = (col, device) => {
|
||||||
switch (col.id) {
|
switch (col.id) {
|
||||||
case "serial": return (
|
case "serial": return (
|
||||||
@@ -756,7 +880,18 @@ export default function DeviceInventory() {
|
|||||||
case "status": return <StatusBadge status={device.mfg_status} />;
|
case "status": return <StatusBadge status={device.mfg_status} />;
|
||||||
case "batch": return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.mfg_batch_id || "—"}</span>;
|
case "batch": return <span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.mfg_batch_id || "—"}</span>;
|
||||||
case "created": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{formatDate(device.created_at)}</span>;
|
case "created": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{formatDate(device.created_at)}</span>;
|
||||||
case "owner": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.owner || "—"}</span>;
|
case "owner": {
|
||||||
|
if (!device.customer_id) return <span className="text-xs" style={{ color: "var(--text-muted)" }}>—</span>;
|
||||||
|
const cust = customerMap[device.customer_id];
|
||||||
|
const fullName = cust ? [cust.name, cust.surname].filter(Boolean).join(" ") : "";
|
||||||
|
return (
|
||||||
|
<span className="text-sm font-medium" style={{ color: fullName ? "var(--text-primary)" : "var(--text-muted)", whiteSpace: "nowrap" }}>
|
||||||
|
{fullName || "—"}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "users":
|
||||||
|
return <UsersHoverCell userList={device.user_list || []} resolvedUsersMap={resolvedUsersMap} />;
|
||||||
case "name": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.device_name || "—"}</span>;
|
case "name": return <span className="text-xs" style={{ color: "var(--text-muted)" }}>{device.device_name || "—"}</span>;
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
@@ -845,6 +980,17 @@ export default function DeviceInventory() {
|
|||||||
onChange={updateColVisible}
|
onChange={updateColVisible}
|
||||||
onReorder={updateColOrder}
|
onReorder={updateColOrder}
|
||||||
/>
|
/>
|
||||||
|
<select
|
||||||
|
value={String(pageSize)}
|
||||||
|
onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}
|
||||||
|
className="px-3 py-2 rounded-md text-sm border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", borderColor: "var(--border-input)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
<option value="10">Show 10</option>
|
||||||
|
<option value="20">Show 20</option>
|
||||||
|
<option value="50">Show 50</option>
|
||||||
|
<option value="0">Show All</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Multi-select action bar */}
|
{/* Multi-select action bar */}
|
||||||
@@ -884,24 +1030,29 @@ export default function DeviceInventory() {
|
|||||||
|
|
||||||
{/* Table */}
|
{/* Table */}
|
||||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
<div className="rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto" style={{ overflowX: "auto" }}>
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
{/* Checkbox column */}
|
{visibleCols.map((col, colIdx) => (
|
||||||
<th className="px-3 py-3 w-10">
|
<th key={col.id}
|
||||||
<input
|
className="py-3 font-medium"
|
||||||
type="checkbox"
|
style={{
|
||||||
checked={allSelected}
|
color: "var(--text-muted)",
|
||||||
onChange={toggleAll}
|
textAlign: col.id === "status" ? "center" : "left",
|
||||||
className="cursor-pointer"
|
whiteSpace: "nowrap",
|
||||||
/>
|
paddingLeft: 16,
|
||||||
</th>
|
paddingRight: colIdx === visibleCols.length - 1 ? 4 : 16,
|
||||||
{visibleCols.map((col) => (
|
}}>
|
||||||
<th key={col.id} className="px-4 py-3 text-left font-medium" style={{ color: "var(--text-muted)" }}>
|
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
{/* Checkbox header — only shown when something is selected */}
|
||||||
|
<th style={{ width: 36, minWidth: 36, padding: "0 8px", textAlign: "center" }}>
|
||||||
|
{selected.size > 0 && (
|
||||||
|
<input type="checkbox" checked={allSelected} onChange={toggleAll} className="cursor-pointer" />
|
||||||
|
)}
|
||||||
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -918,38 +1069,54 @@ export default function DeviceInventory() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
devices.map((device) => {
|
pagedDevices.map((device, rowIdx) => {
|
||||||
const isSelected = selected.has(device.id);
|
const isSelected = selected.has(device.id);
|
||||||
|
const isAlt = rowIdx % 2 === 1;
|
||||||
|
const isHovered = hoveredRow === device.id;
|
||||||
|
const showCheckbox = isSelected || selected.size > 0 || isHovered;
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={device.id}
|
key={device.id}
|
||||||
className="cursor-pointer transition-colors"
|
className="cursor-pointer transition-colors"
|
||||||
style={{
|
style={{
|
||||||
borderBottom: "1px solid var(--border-secondary)",
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
backgroundColor: isSelected ? "var(--bg-card)" : "",
|
backgroundColor: isSelected ? "var(--bg-card)" : isAlt ? "var(--bg-row-alt)" : "",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => {
|
||||||
|
setHoveredRow(device.id);
|
||||||
|
if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)";
|
||||||
|
}}
|
||||||
|
onMouseLeave={(e) => {
|
||||||
|
setHoveredRow(null);
|
||||||
|
if (!isSelected) e.currentTarget.style.backgroundColor = isAlt ? "var(--bg-row-alt)" : "";
|
||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
|
|
||||||
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
|
|
||||||
>
|
>
|
||||||
{/* Checkbox */}
|
|
||||||
<td className="px-3 py-3" onClick={(e) => toggleRow(device.id, e)}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={() => {}}
|
|
||||||
className="cursor-pointer"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
{/* Data cells */}
|
{/* Data cells */}
|
||||||
{visibleCols.map((col) => (
|
{visibleCols.map((col, colIdx) => (
|
||||||
<td
|
<td
|
||||||
key={col.id}
|
key={col.id}
|
||||||
className="px-4 py-3"
|
className="py-3"
|
||||||
|
style={{
|
||||||
|
textAlign: col.id === "status" ? "center" : "left",
|
||||||
|
paddingLeft: 16,
|
||||||
|
paddingRight: colIdx === visibleCols.length - 1 ? 4 : 16,
|
||||||
|
}}
|
||||||
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
|
onClick={() => navigate(`/manufacturing/devices/${device.serial_number}`)}
|
||||||
>
|
>
|
||||||
{renderCell(col, device)}
|
{renderCell(col, device)}
|
||||||
</td>
|
</td>
|
||||||
))}
|
))}
|
||||||
|
{/* Hover-reveal checkbox on the right */}
|
||||||
|
<td style={{ width: 36, minWidth: 36, padding: "0 8px", textAlign: "center" }}
|
||||||
|
onClick={(e) => toggleRow(device.id, e)}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => {}}
|
||||||
|
className="cursor-pointer"
|
||||||
|
style={{ opacity: showCheckbox ? 1 : 0, transition: "opacity 0.15s" }}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
@@ -957,6 +1124,42 @@ export default function DeviceInventory() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{pageSize > 0 && totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Page {safePage} of {totalPages} — {devices.length} total
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => setPage(1)} disabled={safePage === 1}
|
||||||
|
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>«</button>
|
||||||
|
<button onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={safePage === 1}
|
||||||
|
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>‹</button>
|
||||||
|
{Array.from({ length: totalPages }, (_, i) => i + 1)
|
||||||
|
.filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 2)
|
||||||
|
.reduce((acc, p, idx, arr) => {
|
||||||
|
if (idx > 0 && p - arr[idx - 1] > 1) acc.push("…");
|
||||||
|
acc.push(p);
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
.map((p, idx) => p === "…"
|
||||||
|
? <span key={`e${idx}`} className="px-1 text-xs" style={{ color: "var(--text-muted)" }}>…</span>
|
||||||
|
: <button key={p} onClick={() => setPage(p)}
|
||||||
|
className="px-2 py-1 text-xs rounded cursor-pointer"
|
||||||
|
style={{ backgroundColor: p === safePage ? "var(--accent)" : "var(--bg-card-hover)", color: p === safePage ? "var(--bg-primary)" : "var(--text-secondary)", fontWeight: p === safePage ? 700 : 400 }}>
|
||||||
|
{p}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={safePage === totalPages}
|
||||||
|
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>›</button>
|
||||||
|
<button onClick={() => setPage(totalPages)} disabled={safePage === totalPages}
|
||||||
|
className="px-2 py-1 text-xs rounded disabled:opacity-40 cursor-pointer"
|
||||||
|
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}>»</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -508,13 +508,168 @@ function BoardTypeTile({ bt, isSelected, pal, onClick }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bespoke Picker Modal ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
function BespokePickerModal({ onConfirm, onClose }) {
|
||||||
|
const [firmwares, setFirmwares] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [selected, setSelected] = useState(null);
|
||||||
|
const [hwFamily, setHwFamily] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/firmware?hw_type=bespoke")
|
||||||
|
.then((data) => setFirmwares(data.firmware || []))
|
||||||
|
.catch((err) => setError(err.message))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!selected) return;
|
||||||
|
onConfirm({ firmware: selected, hwFamily });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
||||||
|
<div
|
||||||
|
className="rounded-lg border flex flex-col"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "var(--bg-card)",
|
||||||
|
borderColor: "var(--border-primary)",
|
||||||
|
width: "100%", maxWidth: 560,
|
||||||
|
maxHeight: "80vh",
|
||||||
|
margin: "1rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Select Bespoke Firmware</h2>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Choose a bespoke firmware and the hardware family to register in NVS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={onClose} className="cursor-pointer hover:opacity-70 transition-opacity" style={{ color: "var(--text-muted)" }}>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-5 space-y-4" style={{ minHeight: 0 }}>
|
||||||
|
{loading && (
|
||||||
|
<p className="text-sm text-center py-6" style={{ color: "var(--text-muted)" }}>Loading bespoke firmwares…</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loading && !error && firmwares.length === 0 && (
|
||||||
|
<p className="text-sm text-center py-6" style={{ color: "var(--text-muted)" }}>
|
||||||
|
No bespoke firmwares uploaded yet. Upload one from the Firmware Manager.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{firmwares.length > 0 && (
|
||||||
|
<div className="rounded-md border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr style={{ backgroundColor: "var(--bg-secondary)", borderBottom: "1px solid var(--border-primary)" }}>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>UID</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Version</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Channel</th>
|
||||||
|
<th className="px-3 py-2 text-left text-xs font-medium" style={{ color: "var(--text-muted)" }}>Size</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{firmwares.map((fw) => {
|
||||||
|
const isSelected = selected?.id === fw.id;
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={fw.id}
|
||||||
|
onClick={() => setSelected(fw)}
|
||||||
|
className="cursor-pointer transition-colors"
|
||||||
|
style={{
|
||||||
|
borderBottom: "1px solid var(--border-secondary)",
|
||||||
|
backgroundColor: isSelected ? "var(--badge-blue-bg)" : "",
|
||||||
|
outline: isSelected ? "1px solid var(--badge-blue-text)" : "none",
|
||||||
|
}}
|
||||||
|
onMouseEnter={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = "var(--bg-card-hover)"; }}
|
||||||
|
onMouseLeave={(e) => { if (!isSelected) e.currentTarget.style.backgroundColor = ""; }}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-xs font-medium" style={{ color: "var(--text-primary)" }}>
|
||||||
|
{fw.bespoke_uid || "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5 font-mono text-xs" style={{ color: "var(--text-secondary)" }}>{fw.version}</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>{fw.channel}</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{fw.size_bytes ? `${(fw.size_bytes / 1024).toFixed(1)} KB` : "—"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* HW Family — free text */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium mb-1.5" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Hardware Family for NVS
|
||||||
|
</label>
|
||||||
|
<p className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
|
||||||
|
This value will be written to NVS as <span style={{ fontFamily: "monospace" }}>hw_type</span>. The device will identify as this family.
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={hwFamily}
|
||||||
|
onChange={(e) => setHwFamily(e.target.value)}
|
||||||
|
placeholder="e.g. vesper_plus"
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-5 py-4 border-t flex items-center justify-between gap-3" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
hw_revision will be set to <span style={{ fontFamily: "monospace" }}>1.0</span> for bespoke devices.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm rounded-md hover:opacity-80 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
disabled={!selected || !hwFamily.trim()}
|
||||||
|
className="px-5 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer disabled:opacity-40"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
Continue →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
|
// ─── Step 1b — Deploy New: pick board type + revision ─────────────────────────
|
||||||
|
|
||||||
function StepDeployNew({ onSelected, onCreatedSn }) {
|
function StepDeployNew({ onSelected, onCreatedSn, onBespokeSelected }) {
|
||||||
const [boardType, setBoardType] = useState(null);
|
const [boardType, setBoardType] = useState(null);
|
||||||
const [boardVersion, setBoardVersion] = useState("1.0");
|
const [boardVersion, setBoardVersion] = useState("1.0");
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [showBespokePicker, setShowBespokePicker] = useState(false);
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
if (!boardType || !boardVersion.trim()) return;
|
if (!boardType || !boardVersion.trim()) return;
|
||||||
@@ -537,6 +692,11 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBespokeConfirm = ({ firmware, hwFamily }) => {
|
||||||
|
setShowBespokePicker(false);
|
||||||
|
onBespokeSelected({ firmware, hwFamily });
|
||||||
|
};
|
||||||
|
|
||||||
// Group boards by family for row layout
|
// Group boards by family for row layout
|
||||||
const vesperBoards = BOARD_TYPES.filter((b) => b.family === "vesper");
|
const vesperBoards = BOARD_TYPES.filter((b) => b.family === "vesper");
|
||||||
const agnusBoards = BOARD_TYPES.filter((b) => b.family === "agnus");
|
const agnusBoards = BOARD_TYPES.filter((b) => b.family === "agnus");
|
||||||
@@ -626,6 +786,40 @@ function StepDeployNew({ onSelected, onCreatedSn }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bespoke divider */}
|
||||||
|
<div className="flex items-center gap-3 pt-1">
|
||||||
|
<div style={{ flex: 1, height: 1, backgroundColor: "var(--border-secondary)" }} />
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>or</span>
|
||||||
|
<div style={{ flex: 1, height: 1, backgroundColor: "var(--border-secondary)" }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bespoke option */}
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-4 flex items-center justify-between gap-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Select Bespoke Firmware</p>
|
||||||
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Flash a one-off bespoke firmware with a custom hardware family written to NVS.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowBespokePicker(true)}
|
||||||
|
className="px-4 py-2 text-sm rounded-md font-medium hover:opacity-90 transition-opacity cursor-pointer"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)", flexShrink: 0 }}
|
||||||
|
>
|
||||||
|
Select Bespoke →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showBespokePicker && (
|
||||||
|
<BespokePickerModal
|
||||||
|
onConfirm={handleBespokeConfirm}
|
||||||
|
onClose={() => setShowBespokePicker(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -643,7 +837,7 @@ function InfoCell({ label, value, mono = false }) {
|
|||||||
|
|
||||||
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
|
// ─── Step 2 — Flash ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function StepFlash({ device, onFlashed }) {
|
function StepFlash({ device, bespokeOverride, onFlashed }) {
|
||||||
const [portConnected, setPortConnected] = useState(false);
|
const [portConnected, setPortConnected] = useState(false);
|
||||||
const [portName, setPortName] = useState("");
|
const [portName, setPortName] = useState("");
|
||||||
const [connecting, setConnecting] = useState(false);
|
const [connecting, setConnecting] = useState(false);
|
||||||
@@ -791,20 +985,35 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
// 1. Fetch binaries
|
// 1. Fetch binaries
|
||||||
const sn = device.serial_number;
|
const sn = device.serial_number;
|
||||||
|
|
||||||
|
// For bespoke: use the hwFamily's flash assets and override NVS params.
|
||||||
|
// The bootloader/partitions endpoints accept an optional hw_type_override query param.
|
||||||
|
const blUrl = bespokeOverride
|
||||||
|
? `/api/manufacturing/devices/${sn}/bootloader.bin?hw_type_override=${bespokeOverride.hwFamily}`
|
||||||
|
: `/api/manufacturing/devices/${sn}/bootloader.bin`;
|
||||||
|
const partUrl = bespokeOverride
|
||||||
|
? `/api/manufacturing/devices/${sn}/partitions.bin?hw_type_override=${bespokeOverride.hwFamily}`
|
||||||
|
: `/api/manufacturing/devices/${sn}/partitions.bin`;
|
||||||
|
const nvsUrl = bespokeOverride
|
||||||
|
? `/api/manufacturing/devices/${sn}/nvs.bin?hw_type_override=${bespokeOverride.hwFamily}&hw_revision_override=1.0`
|
||||||
|
: `/api/manufacturing/devices/${sn}/nvs.bin`;
|
||||||
|
|
||||||
appendLog("Fetching bootloader binary…");
|
appendLog("Fetching bootloader binary…");
|
||||||
const blBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/bootloader.bin`);
|
const blBuffer = await fetchBinary(blUrl);
|
||||||
appendLog(`Bootloader: ${blBuffer.byteLength} bytes`);
|
appendLog(`Bootloader: ${blBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
appendLog("Fetching partition table binary…");
|
appendLog("Fetching partition table binary…");
|
||||||
const partBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/partitions.bin`);
|
const partBuffer = await fetchBinary(partUrl);
|
||||||
appendLog(`Partition table: ${partBuffer.byteLength} bytes`);
|
appendLog(`Partition table: ${partBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
appendLog("Fetching NVS binary…");
|
appendLog("Fetching NVS binary…");
|
||||||
const nvsBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/nvs.bin`);
|
const nvsBuffer = await fetchBinary(nvsUrl);
|
||||||
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
|
appendLog(`NVS: ${nvsBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
appendLog("Fetching firmware binary…");
|
appendLog("Fetching firmware binary…");
|
||||||
const fwBuffer = await fetchBinary(`/api/manufacturing/devices/${sn}/firmware.bin`);
|
const fwUrl = bespokeOverride
|
||||||
|
? `/api/firmware/bespoke/${bespokeOverride.firmware.channel}/${bespokeOverride.firmware.version}/firmware.bin`
|
||||||
|
: `/api/manufacturing/devices/${sn}/firmware.bin`;
|
||||||
|
const fwBuffer = await fetchBinary(fwUrl);
|
||||||
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
|
appendLog(`Firmware: ${fwBuffer.byteLength} bytes`);
|
||||||
|
|
||||||
// 2. Connect ESPLoader
|
// 2. Connect ESPLoader
|
||||||
@@ -924,7 +1133,23 @@ function StepFlash({ device, onFlashed }) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
<div className="grid grid-cols-2 gap-3 mb-4">
|
||||||
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
<InfoCell label="Serial Number" value={device.serial_number} mono />
|
||||||
|
{bespokeOverride ? (
|
||||||
|
<>
|
||||||
|
<InfoCell label="NVS hw_type" value={bespokeOverride.hwFamily} />
|
||||||
|
<InfoCell label="NVS hw_revision" value="1.0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase tracking-wide mb-0.5" style={{ color: "var(--text-muted)" }}>Firmware</p>
|
||||||
|
<p className="text-xs font-mono" style={{ color: "#fb923c" }}>
|
||||||
|
BESPOKE · {bespokeOverride.firmware.bespoke_uid}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-mono mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
|
v{bespokeOverride.firmware.version} / {bespokeOverride.firmware.channel}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
|
<InfoCell label="Board" value={`${BOARD_TYPE_LABELS[device.hw_type] || device.hw_type} ${formatHwVersion(device.hw_version)}`} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!webSerialAvailable && (
|
{!webSerialAvailable && (
|
||||||
@@ -1382,6 +1607,8 @@ export default function ProvisioningWizard() {
|
|||||||
const [mode, setMode] = useState(null); // "existing" | "new"
|
const [mode, setMode] = useState(null); // "existing" | "new"
|
||||||
const [device, setDevice] = useState(null);
|
const [device, setDevice] = useState(null);
|
||||||
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
|
const [createdSn, setCreatedSn] = useState(null); // SN created in Deploy New, for abort cleanup
|
||||||
|
// Bespoke override: { firmware: FirmwareVersion, hwFamily: string } | null
|
||||||
|
const [bespokeOverride, setBespokeOverride] = useState(null);
|
||||||
|
|
||||||
const handleModePicked = (m) => {
|
const handleModePicked = (m) => {
|
||||||
setMode(m);
|
setMode(m);
|
||||||
@@ -1389,10 +1616,38 @@ export default function ProvisioningWizard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeviceSelected = (dev) => {
|
const handleDeviceSelected = (dev) => {
|
||||||
|
setBespokeOverride(null);
|
||||||
setDevice(dev);
|
setDevice(dev);
|
||||||
setStep(2);
|
setStep(2);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Called from StepDeployNew when user picks a bespoke firmware.
|
||||||
|
// We skip serial generation and go straight to flash with a synthetic device stub.
|
||||||
|
const handleBespokeSelected = ({ firmware, hwFamily }) => {
|
||||||
|
setBespokeOverride({ firmware, hwFamily });
|
||||||
|
// Create a minimal device-like object — serial will be generated on this step
|
||||||
|
// but for bespoke we still need a real serial. Trigger normal Deploy New flow
|
||||||
|
// with a placeholder boardType that maps to hwFamily, then override at flash time.
|
||||||
|
// For simplicity: generate a serial with board_type=hwFamily, board_version="1.0".
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const batch = await api.post("/manufacturing/batch", {
|
||||||
|
board_type: "vesper", // placeholder — NVS will be overridden with the bespoke hwFamily
|
||||||
|
board_version: "1.0",
|
||||||
|
quantity: 1,
|
||||||
|
});
|
||||||
|
const sn = batch.serial_numbers[0];
|
||||||
|
setCreatedSn(sn);
|
||||||
|
const dev = await api.get(`/manufacturing/devices/${sn}`);
|
||||||
|
setDevice(dev);
|
||||||
|
setStep(2);
|
||||||
|
} catch (err) {
|
||||||
|
// eslint-disable-next-line no-console
|
||||||
|
console.error("Failed to create bespoke serial:", err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
const handleFlashed = () => setStep(3);
|
const handleFlashed = () => setStep(3);
|
||||||
|
|
||||||
const handleVerified = (updatedDevice) => {
|
const handleVerified = (updatedDevice) => {
|
||||||
@@ -1410,9 +1665,11 @@ export default function ProvisioningWizard() {
|
|||||||
setStep(0);
|
setStep(0);
|
||||||
setMode(null);
|
setMode(null);
|
||||||
setDevice(null);
|
setDevice(null);
|
||||||
|
setBespokeOverride(null);
|
||||||
} else if (step === 2) {
|
} else if (step === 2) {
|
||||||
setStep(1);
|
setStep(1);
|
||||||
setDevice(null);
|
setDevice(null);
|
||||||
|
setBespokeOverride(null);
|
||||||
} else if (step === 3) {
|
} else if (step === 3) {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
}
|
}
|
||||||
@@ -1426,11 +1683,13 @@ export default function ProvisioningWizard() {
|
|||||||
setStep(0);
|
setStep(0);
|
||||||
setMode(null);
|
setMode(null);
|
||||||
setDevice(null);
|
setDevice(null);
|
||||||
|
setBespokeOverride(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleProvisionNext = () => {
|
const handleProvisionNext = () => {
|
||||||
setDevice(null);
|
setDevice(null);
|
||||||
setCreatedSn(null);
|
setCreatedSn(null);
|
||||||
|
setBespokeOverride(null);
|
||||||
setStep(0);
|
setStep(0);
|
||||||
setMode(null);
|
setMode(null);
|
||||||
};
|
};
|
||||||
@@ -1511,11 +1770,12 @@ export default function ProvisioningWizard() {
|
|||||||
<StepDeployNew
|
<StepDeployNew
|
||||||
onSelected={handleDeviceSelected}
|
onSelected={handleDeviceSelected}
|
||||||
onCreatedSn={(sn) => setCreatedSn(sn)}
|
onCreatedSn={(sn) => setCreatedSn(sn)}
|
||||||
|
onBespokeSelected={handleBespokeSelected}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{step === 2 && device && (
|
{step === 2 && device && (
|
||||||
<StepFlash device={device} onFlashed={handleFlashed} />
|
<StepFlash device={device} bespokeOverride={bespokeOverride} onFlashed={handleFlashed} />
|
||||||
)}
|
)}
|
||||||
{step === 3 && device && (
|
{step === 3 && device && (
|
||||||
<StepVerify device={device} onVerified={handleVerified} />
|
<StepVerify device={device} onVerified={handleVerified} />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate, useLocation } from "react-router-dom";
|
||||||
import api from "../api/client";
|
import api from "../api/client";
|
||||||
|
|
||||||
const MAX_NOTES = 16;
|
const MAX_NOTES = 16;
|
||||||
@@ -106,10 +106,48 @@ function playStep(audioCtx, stepValue, noteDurationMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function csvToSteps(csv) {
|
||||||
|
if (!csv || !csv.trim()) return null;
|
||||||
|
return csv.trim().split(",").map((token) => {
|
||||||
|
const parts = token.split("+");
|
||||||
|
let val = 0;
|
||||||
|
for (const p of parts) {
|
||||||
|
const n = parseInt(p.trim(), 10);
|
||||||
|
if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1));
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export default function MelodyComposer() {
|
export default function MelodyComposer() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
|
const { state: routeState } = useLocation();
|
||||||
const [noteCount, setNoteCount] = useState(8);
|
const loadedArchetype = routeState?.archetype || null;
|
||||||
|
|
||||||
|
const initialSteps = () => {
|
||||||
|
if (loadedArchetype?.steps) {
|
||||||
|
const parsed = csvToSteps(loadedArchetype.steps);
|
||||||
|
if (parsed?.length) return parsed;
|
||||||
|
}
|
||||||
|
return Array.from({ length: 16 }, () => 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const [steps, setSteps] = useState(initialSteps);
|
||||||
|
const [noteCount, setNoteCount] = useState(() => {
|
||||||
|
if (loadedArchetype?.steps) {
|
||||||
|
const parsed = csvToSteps(loadedArchetype.steps);
|
||||||
|
if (parsed?.length) {
|
||||||
|
let maxBit = 0;
|
||||||
|
for (const v of parsed) {
|
||||||
|
for (let b = 15; b >= 0; b--) {
|
||||||
|
if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Math.max(8, maxBit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 8;
|
||||||
|
});
|
||||||
const [stepDelayMs, setStepDelayMs] = useState(280);
|
const [stepDelayMs, setStepDelayMs] = useState(280);
|
||||||
const [noteDurationMs, setNoteDurationMs] = useState(110);
|
const [noteDurationMs, setNoteDurationMs] = useState(110);
|
||||||
const [measureEvery, setMeasureEvery] = useState(4);
|
const [measureEvery, setMeasureEvery] = useState(4);
|
||||||
@@ -123,6 +161,7 @@ export default function MelodyComposer() {
|
|||||||
const [deployPid, setDeployPid] = useState("");
|
const [deployPid, setDeployPid] = useState("");
|
||||||
const [deployError, setDeployError] = useState("");
|
const [deployError, setDeployError] = useState("");
|
||||||
const [deploying, setDeploying] = useState(false);
|
const [deploying, setDeploying] = useState(false);
|
||||||
|
const [deployMode, setDeployMode] = useState("new"); // "new" | "update"
|
||||||
const [noteColors, setNoteColors] = useState([]);
|
const [noteColors, setNoteColors] = useState([]);
|
||||||
const [stepMenuIndex, setStepMenuIndex] = useState(null);
|
const [stepMenuIndex, setStepMenuIndex] = useState(null);
|
||||||
|
|
||||||
@@ -290,10 +329,18 @@ export default function MelodyComposer() {
|
|||||||
scheduleStep(0);
|
scheduleStep(0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeployModal = () => {
|
const openDeployModal = (mode = "new") => {
|
||||||
setError("");
|
setError("");
|
||||||
setSuccessMsg("");
|
setSuccessMsg("");
|
||||||
setDeployError("");
|
setDeployError("");
|
||||||
|
setDeployMode(mode);
|
||||||
|
if (mode === "update" && loadedArchetype) {
|
||||||
|
setDeployName(loadedArchetype.name || "");
|
||||||
|
setDeployPid(loadedArchetype.pid || "");
|
||||||
|
} else {
|
||||||
|
setDeployName("");
|
||||||
|
setDeployPid("");
|
||||||
|
}
|
||||||
setShowDeployModal(true);
|
setShowDeployModal(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -306,45 +353,34 @@ export default function MelodyComposer() {
|
|||||||
const handleDeploy = async () => {
|
const handleDeploy = async () => {
|
||||||
const name = deployName.trim();
|
const name = deployName.trim();
|
||||||
const pid = deployPid.trim();
|
const pid = deployPid.trim();
|
||||||
if (!name) {
|
if (!name) { setDeployError("Name is required."); return; }
|
||||||
setDeployError("Name is required.");
|
if (!pid) { setDeployError("PID is required."); return; }
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pid) {
|
|
||||||
setDeployError("PID is required.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDeploying(true);
|
setDeploying(true);
|
||||||
setDeployError("");
|
setDeployError("");
|
||||||
setSuccessMsg("");
|
setSuccessMsg("");
|
||||||
try {
|
try {
|
||||||
|
const stepsStr = steps.map(stepToNotation).join(",");
|
||||||
|
|
||||||
|
if (deployMode === "update" && loadedArchetype?.id) {
|
||||||
|
const updated = await api.put(`/builder/melodies/${loadedArchetype.id}`, { name, pid, steps: stepsStr });
|
||||||
|
setSuccessMsg(`Archetype "${name}" updated successfully.`);
|
||||||
|
setShowDeployModal(false);
|
||||||
|
if (updated?.id) navigate(`/melodies/archetypes/${updated.id}`);
|
||||||
|
} else {
|
||||||
const existing = await api.get("/builder/melodies");
|
const existing = await api.get("/builder/melodies");
|
||||||
const list = existing.melodies || [];
|
const list = existing.melodies || [];
|
||||||
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
||||||
if (dupName) {
|
if (dupName) { setDeployError(`An archetype with the name "${name}" already exists.`); return; }
|
||||||
setDeployError(`An archetype with the name "${name}" already exists.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
|
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
|
||||||
if (dupPid) {
|
if (dupPid) { setDeployError(`An archetype with the PID "${pid}" already exists.`); return; }
|
||||||
setDeployError(`An archetype with the PID "${pid}" already exists.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepsStr = steps.map(stepToNotation).join(",");
|
|
||||||
const created = await api.post("/builder/melodies", {
|
|
||||||
name,
|
|
||||||
pid,
|
|
||||||
steps: stepsStr,
|
|
||||||
});
|
|
||||||
|
|
||||||
|
const created = await api.post("/builder/melodies", { name, pid, steps: stepsStr });
|
||||||
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
|
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
|
||||||
setShowDeployModal(false);
|
setShowDeployModal(false);
|
||||||
setDeployName("");
|
setDeployName("");
|
||||||
setDeployPid("");
|
setDeployPid("");
|
||||||
if (created?.id) {
|
if (created?.id) navigate(`/melodies/archetypes/${created.id}`);
|
||||||
navigate(`/melodies/archetypes/${created.id}`);
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setDeployError(err.message);
|
setDeployError(err.message);
|
||||||
@@ -375,6 +411,24 @@ export default function MelodyComposer() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{loadedArchetype && (
|
||||||
|
<div className="rounded-lg border px-4 py-3 flex items-center gap-3"
|
||||||
|
style={{ backgroundColor: "rgba(139,92,246,0.08)", borderColor: "rgba(139,92,246,0.3)" }}>
|
||||||
|
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: "#a78bfa" }}>Editing Archetype</span>
|
||||||
|
<span className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{loadedArchetype.name}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--text-muted)" }}>·</span>
|
||||||
|
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{loadedArchetype.id}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate("/melodies/composer", { replace: true, state: null })}
|
||||||
|
className="ml-auto text-xs px-2 py-1 rounded"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div
|
<div
|
||||||
className="text-sm rounded-md p-3 border"
|
className="text-sm rounded-md p-3 border"
|
||||||
@@ -422,7 +476,14 @@ export default function MelodyComposer() {
|
|||||||
)}
|
)}
|
||||||
<span>{steps.length} steps, {noteCount} notes</span>
|
<span>{steps.length} steps, {noteCount} notes</span>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" onClick={openDeployModal} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
|
{loadedArchetype ? (
|
||||||
|
<>
|
||||||
|
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>Deploy as New Archetype</button>
|
||||||
|
<button type="button" onClick={() => openDeployModal("update")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Update Current Archetype</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -666,10 +727,12 @@ export default function MelodyComposer() {
|
|||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
Deploy Archetype
|
{deployMode === "update" ? "Update Archetype" : "Deploy Archetype"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||||
Create a new archetype from this composer pattern.
|
{deployMode === "update"
|
||||||
|
? "Rebuild the existing archetype with the current composer pattern."
|
||||||
|
: "Create a new archetype from this composer pattern."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -744,7 +807,7 @@ export default function MelodyComposer() {
|
|||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
disabled={deploying}
|
disabled={deploying}
|
||||||
>
|
>
|
||||||
{deploying ? "Deploying..." : "Deploy"}
|
{deploying ? (deployMode === "update" ? "Updating..." : "Deploying...") : (deployMode === "update" ? "Update" : "Deploy")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -120,7 +120,6 @@ export default function MelodyDetail() {
|
|||||||
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
|
||||||
const [showPlayback, setShowPlayback] = useState(false);
|
const [showPlayback, setShowPlayback] = useState(false);
|
||||||
const [showBinaryView, setShowBinaryView] = useState(false);
|
const [showBinaryView, setShowBinaryView] = useState(false);
|
||||||
const [offlineSaving, setOfflineSaving] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get("/settings/melody").then((ms) => {
|
api.get("/settings/melody").then((ms) => {
|
||||||
@@ -192,31 +191,6 @@ export default function MelodyDetail() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleAvailableOffline = async (nextValue) => {
|
|
||||||
if (!canEdit || !melody) return;
|
|
||||||
setOfflineSaving(true);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
information: { ...(melody.information || {}), available_offline: nextValue },
|
|
||||||
default_settings: melody.default_settings || {},
|
|
||||||
type: melody.type || "all",
|
|
||||||
uid: melody.uid || "",
|
|
||||||
pid: melody.pid || "",
|
|
||||||
metadata: melody.metadata || {},
|
|
||||||
};
|
|
||||||
if (melody.url) body.url = melody.url;
|
|
||||||
await api.put(`/melodies/${id}`, body);
|
|
||||||
setMelody((prev) => ({
|
|
||||||
...prev,
|
|
||||||
information: { ...(prev?.information || {}), available_offline: nextValue },
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setOfflineSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
|
||||||
@@ -345,6 +319,26 @@ export default function MelodyDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{info.outdated_archetype && (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-3 rounded-md px-4 py-3 text-sm border mb-6"
|
||||||
|
style={{
|
||||||
|
backgroundColor: "rgba(245,158,11,0.08)",
|
||||||
|
borderColor: "rgba(245,158,11,0.35)",
|
||||||
|
color: "#f59e0b",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg className="mt-0.5 shrink-0" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
||||||
|
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
||||||
|
</svg>
|
||||||
|
<span>
|
||||||
|
<strong>Outdated Archetype</strong> — The archetype assigned to this melody has been changed or removed.
|
||||||
|
Re-assign an archetype in the editor to update and clear this warning.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
{/* Left column */}
|
{/* Left column */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -511,20 +505,6 @@ export default function MelodyDetail() {
|
|||||||
<h2 className="ui-section-card__title">Files</h2>
|
<h2 className="ui-section-card__title">Files</h2>
|
||||||
</div>
|
</div>
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
<Field label="Available as Built-In">
|
|
||||||
<label className="inline-flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={Boolean(info.available_offline)}
|
|
||||||
disabled={!canEdit || offlineSaving}
|
|
||||||
onChange={(e) => handleToggleAvailableOffline(e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded"
|
|
||||||
/>
|
|
||||||
<span style={{ color: "var(--text-secondary)" }}>
|
|
||||||
{info.available_offline ? "Enabled" : "Disabled"}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</Field>
|
|
||||||
<Field label="Binary File">
|
<Field label="Binary File">
|
||||||
{(() => {
|
{(() => {
|
||||||
// Common source of truth: assigned archetype binary first, then melody URL, then uploaded file URL.
|
// Common source of truth: assigned archetype binary first, then melody URL, then uploaded file URL.
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ const defaultInfo = {
|
|||||||
steps: 0,
|
steps: 0,
|
||||||
color: "",
|
color: "",
|
||||||
isTrueRing: false,
|
isTrueRing: false,
|
||||||
available_offline: false,
|
|
||||||
previewURL: "",
|
previewURL: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -757,18 +756,6 @@ export default function MelodyForm() {
|
|||||||
<h2 className="ui-section-card__title">Files</h2>
|
<h2 className="ui-section-card__title">Files</h2>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="available-offline"
|
|
||||||
checked={Boolean(information.available_offline)}
|
|
||||||
onChange={(e) => updateInfo("available_offline", e.target.checked)}
|
|
||||||
className="h-4 w-4 rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="available-offline" className="text-sm font-medium" style={labelStyle}>
|
|
||||||
Available as Built-In
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-form-label">Binary File (.bsm)</label>
|
<label className="ui-form-label">Binary File (.bsm)</label>
|
||||||
{(() => {
|
{(() => {
|
||||||
@@ -1024,6 +1011,7 @@ export default function MelodyForm() {
|
|||||||
if (archetype.steps != null) updateInfo("steps", archetype.steps);
|
if (archetype.steps != null) updateInfo("steps", archetype.steps);
|
||||||
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
|
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
|
||||||
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
|
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
|
||||||
|
updateInfo("outdated_archetype", false);
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
loadMelody();
|
loadMelody();
|
||||||
} else {
|
} else {
|
||||||
@@ -1059,6 +1047,7 @@ export default function MelodyForm() {
|
|||||||
if (archetype.steps != null) updateInfo("steps", archetype.steps);
|
if (archetype.steps != null) updateInfo("steps", archetype.steps);
|
||||||
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
|
if (archetype.totalNotes != null) updateInfo("totalNotes", archetype.totalNotes);
|
||||||
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
|
if (archetype.archetype_csv != null) updateInfo("archetype_csv", archetype.archetype_csv);
|
||||||
|
updateInfo("outdated_archetype", false);
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
loadMelody();
|
loadMelody();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const ALL_COLUMNS = [
|
|||||||
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
{ key: "pauseDuration", label: "Pause", defaultOn: false },
|
||||||
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
|
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
|
||||||
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
|
||||||
{ key: "builtIn", label: "Built-in", defaultOn: false },
|
|
||||||
{ key: "binaryFile", label: "Binary File", defaultOn: false },
|
{ key: "binaryFile", label: "Binary File", defaultOn: false },
|
||||||
{ key: "dateCreated", label: "Date Created", defaultOn: false },
|
{ key: "dateCreated", label: "Date Created", defaultOn: false },
|
||||||
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
|
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
|
||||||
@@ -300,57 +299,6 @@ function formatHex16(value) {
|
|||||||
return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
|
return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOfflineCppCode(rows) {
|
|
||||||
const selected = (rows || []).filter((row) => Boolean(row?.information?.available_offline));
|
|
||||||
const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
||||||
|
|
||||||
if (selected.length === 0) {
|
|
||||||
return `// Generated: ${generatedAt}\n// No melodies marked as built-in.\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const arrays = [];
|
|
||||||
const libraryEntries = [];
|
|
||||||
|
|
||||||
for (const row of selected) {
|
|
||||||
const info = row?.information || {};
|
|
||||||
const displayName = getLocalizedValue(info.name, "en", getLocalizedValue(info.name, "en", "Untitled Melody"));
|
|
||||||
const uid = row?.uid || "";
|
|
||||||
const symbol = `melody_builtin_${toSafeCppSymbol(uid || displayName)}`;
|
|
||||||
const steps = parseArchetypeCsv(info.archetype_csv);
|
|
||||||
const stepCount = Number(info.steps || 0);
|
|
||||||
|
|
||||||
arrays.push(`// Melody: ${escapeCppString(displayName)} | UID: ${escapeCppString(uid || "missing_uid")}`);
|
|
||||||
arrays.push(`const uint16_t PROGMEM ${symbol}[] = {`);
|
|
||||||
if (steps.length === 0) {
|
|
||||||
arrays.push(" // No archetype_csv step data found");
|
|
||||||
} else {
|
|
||||||
for (let i = 0; i < steps.length; i += 8) {
|
|
||||||
const chunk = steps.slice(i, i + 8).map(formatHex16).join(", ");
|
|
||||||
arrays.push(` ${chunk}${i + 8 < steps.length ? "," : ""}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
arrays.push("};");
|
|
||||||
arrays.push("");
|
|
||||||
|
|
||||||
libraryEntries.push(" {");
|
|
||||||
libraryEntries.push(` "${escapeCppString(displayName)}",`);
|
|
||||||
libraryEntries.push(` "${escapeCppString(uid || toSafeCppSymbol(displayName))}",`);
|
|
||||||
libraryEntries.push(` ${symbol},`);
|
|
||||||
libraryEntries.push(` ${stepCount > 0 ? stepCount : steps.length}`);
|
|
||||||
libraryEntries.push(" }");
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
`// Generated: ${generatedAt}`,
|
|
||||||
"",
|
|
||||||
...arrays,
|
|
||||||
"// --- Add or replace your MELODY_LIBRARY[] with this: ---",
|
|
||||||
"const MelodyInfo MELODY_LIBRARY[] = {",
|
|
||||||
libraryEntries.map((entry, idx) => `${entry}${idx < libraryEntries.length - 1 ? "," : ""}`).join("\n"),
|
|
||||||
"};",
|
|
||||||
"",
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MelodyList() {
|
export default function MelodyList() {
|
||||||
const [melodies, setMelodies] = useState([]);
|
const [melodies, setMelodies] = useState([]);
|
||||||
@@ -372,8 +320,6 @@ export default function MelodyList() {
|
|||||||
const [actionLoading, setActionLoading] = useState(null);
|
const [actionLoading, setActionLoading] = useState(null);
|
||||||
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
const [colPrefs, setColPrefs] = useState(loadColumnPrefs);
|
||||||
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
|
||||||
const [showOfflineModal, setShowOfflineModal] = useState(false);
|
|
||||||
const [builtInSavingId, setBuiltInSavingId] = useState(null);
|
|
||||||
const [viewRow, setViewRow] = useState(null);
|
const [viewRow, setViewRow] = useState(null);
|
||||||
const [builtMap, setBuiltMap] = useState({});
|
const [builtMap, setBuiltMap] = useState({});
|
||||||
const [pageSize, setPageSize] = useState(20);
|
const [pageSize, setPageSize] = useState(20);
|
||||||
@@ -606,36 +552,6 @@ export default function MelodyList() {
|
|||||||
setViewRow(row);
|
setViewRow(row);
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateBuiltInState = async (e, row, nextValue) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
if (!canEdit) return;
|
|
||||||
setBuiltInSavingId(row.id);
|
|
||||||
setError("");
|
|
||||||
try {
|
|
||||||
const body = {
|
|
||||||
information: { ...(row.information || {}), available_offline: nextValue },
|
|
||||||
default_settings: row.default_settings || {},
|
|
||||||
type: row.type || "all",
|
|
||||||
uid: row.uid || "",
|
|
||||||
pid: row.pid || "",
|
|
||||||
metadata: row.metadata || {},
|
|
||||||
};
|
|
||||||
if (row.url) body.url = row.url;
|
|
||||||
await api.put(`/melodies/${row.id}`, body);
|
|
||||||
setMelodies((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.id === row.id
|
|
||||||
? { ...m, information: { ...(m.information || {}), available_offline: nextValue } }
|
|
||||||
: m
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setBuiltInSavingId(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleCreator = (creator) => {
|
const toggleCreator = (creator) => {
|
||||||
setCreatedByFilter((prev) =>
|
setCreatedByFilter((prev) =>
|
||||||
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
|
||||||
@@ -695,16 +611,11 @@ export default function MelodyList() {
|
|||||||
? displayRows.slice((safePage - 1) * pageSize, safePage * pageSize)
|
? displayRows.slice((safePage - 1) * pageSize, safePage * pageSize)
|
||||||
: displayRows;
|
: displayRows;
|
||||||
|
|
||||||
const offlineTaggedCount = useMemo(
|
|
||||||
() => displayRows.filter((row) => Boolean(row?.information?.available_offline)).length,
|
|
||||||
[displayRows]
|
|
||||||
);
|
|
||||||
const hasAnyFilter = Boolean(
|
const hasAnyFilter = Boolean(
|
||||||
search || typeFilter || toneFilter || statusFilter || createdByFilter.length > 0
|
search || typeFilter || toneFilter || statusFilter || createdByFilter.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
const offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]);
|
|
||||||
|
|
||||||
const handleSortClick = (columnKey) => {
|
const handleSortClick = (columnKey) => {
|
||||||
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
|
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
|
||||||
if (!nextSortKey) return;
|
if (!nextSortKey) return;
|
||||||
@@ -729,8 +640,10 @@ export default function MelodyList() {
|
|||||||
const metadata = row.metadata || {};
|
const metadata = row.metadata || {};
|
||||||
|
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "status":
|
case "status": {
|
||||||
|
const isOutdated = Boolean(info.outdated_archetype);
|
||||||
return (
|
return (
|
||||||
|
<div className="flex flex-col items-start gap-1">
|
||||||
<span
|
<span
|
||||||
className="px-2 py-0.5 text-xs font-semibold rounded-full"
|
className="px-2 py-0.5 text-xs font-semibold rounded-full"
|
||||||
style={
|
style={
|
||||||
@@ -741,7 +654,16 @@ export default function MelodyList() {
|
|||||||
>
|
>
|
||||||
{row.status === "published" ? "Live" : "Draft"}
|
{row.status === "published" ? "Live" : "Draft"}
|
||||||
</span>
|
</span>
|
||||||
|
{isOutdated && (
|
||||||
|
<span className="px-1.5 py-0.5 text-xs rounded-full font-medium"
|
||||||
|
style={{ backgroundColor: "rgba(245,158,11,0.15)", color: "#f59e0b", border: "1px solid rgba(245,158,11,0.3)" }}
|
||||||
|
title="This melody's archetype has been changed or removed. Re-assign an archetype to clear this.">
|
||||||
|
Outdated
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case "color":
|
case "color":
|
||||||
return info.color ? (
|
return info.color ? (
|
||||||
<span
|
<span
|
||||||
@@ -754,11 +676,14 @@ export default function MelodyList() {
|
|||||||
);
|
);
|
||||||
case "name": {
|
case "name": {
|
||||||
const description = getLocalizedValue(info.description, displayLang) || "-";
|
const description = getLocalizedValue(info.description, displayLang) || "-";
|
||||||
|
const isOutdated = Boolean(info.outdated_archetype);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className="font-medium" style={{ color: "var(--text-heading)" }}>
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium" style={{ color: isOutdated ? "#f59e0b" : "var(--text-heading)" }}>
|
||||||
{getDisplayName(info.name)}
|
{getDisplayName(info.name)}
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
{isVisible("description") && (
|
{isVisible("description") && (
|
||||||
<p
|
<p
|
||||||
className="text-xs mt-0.5 truncate max-w-xs"
|
className="text-xs mt-0.5 truncate max-w-xs"
|
||||||
@@ -928,36 +853,6 @@ export default function MelodyList() {
|
|||||||
) : (
|
) : (
|
||||||
"-"
|
"-"
|
||||||
);
|
);
|
||||||
case "builtIn": {
|
|
||||||
const enabled = Boolean(info.available_offline);
|
|
||||||
const saving = builtInSavingId === row.id;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => updateBuiltInState(e, row, !enabled)}
|
|
||||||
disabled={!canEdit || saving}
|
|
||||||
className="inline-flex items-center gap-2 cursor-pointer disabled:opacity-50"
|
|
||||||
style={{ background: "none", border: "none", padding: 0, color: "var(--text-secondary)" }}
|
|
||||||
title={canEdit ? "Click to toggle built-in availability" : "Built-in availability"}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-4 h-4 rounded-full border inline-flex items-center justify-center"
|
|
||||||
style={{
|
|
||||||
borderColor: enabled ? "rgba(34,197,94,0.7)" : "var(--border-primary)",
|
|
||||||
backgroundColor: enabled ? "rgba(34,197,94,0.15)" : "transparent",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: enabled ? "#22c55e" : "transparent" }}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
<span className="text-xs" style={{ color: enabled ? "var(--success-text)" : "var(--text-muted)" }}>
|
|
||||||
{enabled ? "Yes" : "No"}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case "binaryFile": {
|
case "binaryFile": {
|
||||||
const resolved = resolveEffectiveBinary(row);
|
const resolved = resolveEffectiveBinary(row);
|
||||||
const binaryUrl = resolved.url;
|
const binaryUrl = resolved.url;
|
||||||
@@ -1068,23 +963,9 @@ export default function MelodyList() {
|
|||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
<span className="text-sm whitespace-nowrap" style={{ color: "var(--text-muted)" }}>
|
||||||
{hasAnyFilter
|
{hasAnyFilter
|
||||||
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length} | ${offlineTaggedCount} offline-tagged`
|
? `Filtered — ${displayRows.length} / ${allMelodyCount || melodies.length}`
|
||||||
: `Showing all (${allMelodyCount || melodies.length}) | ${offlineTaggedCount} tagged for Offline`}
|
: `Showing all (${allMelodyCount || melodies.length})`}
|
||||||
</span>
|
</span>
|
||||||
{canEdit && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowOfflineModal(true)}
|
|
||||||
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer whitespace-nowrap"
|
|
||||||
style={{
|
|
||||||
borderColor: "var(--border-primary)",
|
|
||||||
color: "var(--text-secondary)",
|
|
||||||
backgroundColor: "var(--bg-card)",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Build Offline List
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/melodies/new")}
|
onClick={() => navigate("/melodies/new")}
|
||||||
@@ -1427,67 +1308,6 @@ export default function MelodyList() {
|
|||||||
onCancel={() => setUnpublishTarget(null)}
|
onCancel={() => setUnpublishTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showOfflineModal && (
|
|
||||||
<div
|
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center p-4"
|
|
||||||
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
|
|
||||||
onClick={() => setShowOfflineModal(false)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="w-full max-w-5xl max-h-[85vh] rounded-lg border shadow-xl flex flex-col"
|
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Offline Built-In Code</h2>
|
|
||||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Includes melodies where Built-in = Yes
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={async () => {
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(offlineCode);
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="px-3 py-1.5 text-xs rounded border"
|
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
|
||||||
>
|
|
||||||
Copy
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowOfflineModal(false)}
|
|
||||||
className="px-3 py-1.5 text-xs rounded border"
|
|
||||||
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-5 overflow-auto">
|
|
||||||
<pre
|
|
||||||
className="text-xs rounded-lg p-4 overflow-auto"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-primary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
border: "1px solid var(--border-primary)",
|
|
||||||
maxHeight: "62vh",
|
|
||||||
whiteSpace: "pre",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{offlineCode}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<BinaryTableModal
|
<BinaryTableModal
|
||||||
open={!!viewRow}
|
open={!!viewRow}
|
||||||
melody={viewRow || null}
|
melody={viewRow || null}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { useNavigate, useParams } from "react-router-dom";
|
import { useNavigate, useParams, Link } from "react-router-dom";
|
||||||
import api from "../../api/client";
|
import api from "../../api/client";
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||||
|
import PlaybackModal from "../PlaybackModal";
|
||||||
|
import { getLocalizedValue } from "../melodyUtils";
|
||||||
|
|
||||||
const mutedStyle = { color: "var(--text-muted)" };
|
const mutedStyle = { color: "var(--text-muted)" };
|
||||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||||
@@ -68,12 +70,11 @@ export default function ArchetypeForm() {
|
|||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [pid, setPid] = useState("");
|
const [pid, setPid] = useState("");
|
||||||
const [steps, setSteps] = useState("");
|
const [steps, setSteps] = useState("");
|
||||||
|
const [isBuiltin, setIsBuiltin] = useState(false);
|
||||||
const [savedPid, setSavedPid] = useState("");
|
const [savedPid, setSavedPid] = useState("");
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [buildingBinary, setBuildingBinary] = useState(false);
|
|
||||||
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
|
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
const [successMsg, setSuccessMsg] = useState("");
|
const [successMsg, setSuccessMsg] = useState("");
|
||||||
|
|
||||||
@@ -86,10 +87,19 @@ export default function ArchetypeForm() {
|
|||||||
const [showDelete, setShowDelete] = useState(false);
|
const [showDelete, setShowDelete] = useState(false);
|
||||||
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
|
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
|
||||||
const [assignedCount, setAssignedCount] = useState(0);
|
const [assignedCount, setAssignedCount] = useState(0);
|
||||||
|
const [assignedMelodies, setAssignedMelodies] = useState([]); // [{id, nameRaw}]
|
||||||
|
const [loadingAssigned, setLoadingAssigned] = useState(false);
|
||||||
|
const [primaryLang, setPrimaryLang] = useState("en");
|
||||||
|
|
||||||
|
// Playback
|
||||||
|
const [showPlayback, setShowPlayback] = useState(false);
|
||||||
|
// currentBuiltMelody: the live archetype object (for playback + binary_url)
|
||||||
|
const [currentBuiltMelody, setCurrentBuiltMelody] = useState(null);
|
||||||
|
|
||||||
const codeRef = useRef(null);
|
const codeRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
api.get("/settings/melody").then((ms) => setPrimaryLang(ms.primary_language || "en")).catch(() => {});
|
||||||
if (isEdit) loadArchetype();
|
if (isEdit) loadArchetype();
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
@@ -100,12 +110,18 @@ export default function ArchetypeForm() {
|
|||||||
setName(data.name || "");
|
setName(data.name || "");
|
||||||
setPid(data.pid || "");
|
setPid(data.pid || "");
|
||||||
setSteps(data.steps || "");
|
setSteps(data.steps || "");
|
||||||
|
setIsBuiltin(Boolean(data.is_builtin));
|
||||||
setSavedPid(data.pid || "");
|
setSavedPid(data.pid || "");
|
||||||
setBinaryBuilt(Boolean(data.binary_path));
|
setBinaryBuilt(Boolean(data.binary_path));
|
||||||
setBinaryUrl(data.binary_url || null);
|
setBinaryUrl(data.binary_url || null);
|
||||||
setProgmemCode(data.progmem_code || "");
|
setProgmemCode(data.progmem_code || "");
|
||||||
setAssignedCount(data.assigned_melody_ids?.length || 0);
|
setAssignedCount(data.assigned_melody_ids?.length || 0);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
|
setCurrentBuiltMelody(data);
|
||||||
|
// Load assigned melody details
|
||||||
|
if (data.assigned_melody_ids?.length > 0) {
|
||||||
|
fetchAssignedMelodies(data.assigned_melody_ids);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -113,14 +129,25 @@ export default function ArchetypeForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchAssignedMelodies = async (ids) => {
|
||||||
|
setLoadingAssigned(true);
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)));
|
||||||
|
const details = results
|
||||||
|
.filter((r) => r.status === "fulfilled" && r.value)
|
||||||
|
.map((r) => ({ id: r.value.id, nameRaw: r.value.information?.name }));
|
||||||
|
setAssignedMelodies(details);
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
finally { setLoadingAssigned(false); }
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
|
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
|
||||||
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
|
const handlePidChange = (v) => { setPid(v.toLowerCase().replace(/[^a-z0-9_]/g, "")); setHasUnsavedChanges(true); };
|
||||||
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
|
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) { setError("Name is required."); return; }
|
if (!name.trim()) { setError("Name is required."); return; }
|
||||||
if (!pid.trim()) { setError("PID is required."); return; }
|
if (!pid.trim()) { setError("PID is required."); return; }
|
||||||
|
|
||||||
const stepsError = validateSteps(steps);
|
const stepsError = validateSteps(steps);
|
||||||
if (stepsError) { setError(stepsError); return; }
|
if (stepsError) { setError(stepsError); return; }
|
||||||
|
|
||||||
@@ -130,22 +157,27 @@ export default function ArchetypeForm() {
|
|||||||
try {
|
try {
|
||||||
const existing = await api.get("/builder/melodies");
|
const existing = await api.get("/builder/melodies");
|
||||||
const list = existing.melodies || [];
|
const list = existing.melodies || [];
|
||||||
|
|
||||||
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
|
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
|
||||||
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
|
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
|
||||||
|
|
||||||
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
|
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
|
||||||
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
|
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
|
||||||
|
|
||||||
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
|
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim(), is_builtin: isBuiltin };
|
||||||
|
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await api.put(`/builder/melodies/${id}`, body);
|
// PUT triggers auto-rebuild + outdated flagging on the backend
|
||||||
|
const updated = await api.put(`/builder/melodies/${id}`, body);
|
||||||
setSavedPid(pid.trim());
|
setSavedPid(pid.trim());
|
||||||
|
setBinaryBuilt(Boolean(updated.binary_path));
|
||||||
|
setBinaryUrl(updated.binary_url || null);
|
||||||
|
setProgmemCode(updated.progmem_code || "");
|
||||||
|
setCurrentBuiltMelody(updated);
|
||||||
setHasUnsavedChanges(false);
|
setHasUnsavedChanges(false);
|
||||||
setSuccessMsg("Saved.");
|
setSuccessMsg("Rebuilt and saved successfully.");
|
||||||
} else {
|
} else {
|
||||||
|
// POST triggers auto-build on the backend
|
||||||
const created = await api.post("/builder/melodies", body);
|
const created = await api.post("/builder/melodies", body);
|
||||||
navigate(`/melodies/archetypes/${created.id}`, { replace: true });
|
navigate(`/melodies/archetypes/${created.id}`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
@@ -154,41 +186,6 @@ export default function ArchetypeForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBuildBinary = async () => {
|
|
||||||
if (!isEdit) { setError("Save the archetype first before building."); return; }
|
|
||||||
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
|
|
||||||
setBuildingBinary(true);
|
|
||||||
setError("");
|
|
||||||
setSuccessMsg("");
|
|
||||||
try {
|
|
||||||
const data = await api.post(`/builder/melodies/${id}/build-binary`);
|
|
||||||
setBinaryBuilt(Boolean(data.binary_path));
|
|
||||||
setBinaryUrl(data.binary_url || null);
|
|
||||||
setSuccessMsg("Binary built successfully.");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setBuildingBinary(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBuildBuiltin = async () => {
|
|
||||||
if (!isEdit) { setError("Save the archetype first before building."); return; }
|
|
||||||
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
|
|
||||||
setBuildingBuiltin(true);
|
|
||||||
setError("");
|
|
||||||
setSuccessMsg("");
|
|
||||||
try {
|
|
||||||
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
|
|
||||||
setProgmemCode(data.progmem_code || "");
|
|
||||||
setSuccessMsg("PROGMEM code generated.");
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setBuildingBuiltin(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopy = () => {
|
const handleCopy = () => {
|
||||||
if (!progmemCode) return;
|
if (!progmemCode) return;
|
||||||
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
|
||||||
@@ -205,12 +202,18 @@ export default function ArchetypeForm() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Build a "virtual" builtMelody for playback using current steps (even if unsaved)
|
||||||
|
const playbackBuiltMelody = currentBuiltMelody
|
||||||
|
? { ...currentBuiltMelody, steps: steps }
|
||||||
|
: { id: "preview", name: name || "Preview", pid: pid || "", steps, binary_url: null };
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
|
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{/* Header row */}
|
||||||
<div className="flex items-center justify-between mb-6">
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div>
|
<div>
|
||||||
<button onClick={() => navigate("/melodies/archetypes")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
|
<button onClick={() => navigate("/melodies/archetypes")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
|
||||||
@@ -220,30 +223,62 @@ export default function ArchetypeForm() {
|
|||||||
{isEdit ? "Edit Archetype" : "New Archetype"}
|
{isEdit ? "Edit Archetype" : "New Archetype"}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3">
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Section 1: Built-in + Play */}
|
||||||
|
<button
|
||||||
|
onClick={() => setIsBuiltin((v) => { setHasUnsavedChanges(true); return !v; })}
|
||||||
|
className="px-3 py-2 text-sm rounded-md transition-colors flex items-center gap-1.5"
|
||||||
|
style={isBuiltin
|
||||||
|
? { backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa", border: "1px solid rgba(59,130,246,0.3)" }
|
||||||
|
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }
|
||||||
|
}
|
||||||
|
title="Toggle built-in flag"
|
||||||
|
>
|
||||||
|
{isBuiltin ? "Built-in ✓" : "Built-in"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPlayback(true)}
|
||||||
|
disabled={!steps.trim()}
|
||||||
|
title="Preview current steps"
|
||||||
|
className="px-3 py-2 text-sm rounded-md transition-colors disabled:opacity-40 flex items-center gap-1.5"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "#4ade80", border: "1px solid rgba(74,222,128,0.3)" }}
|
||||||
|
>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" style={{ flexShrink: 0 }}>
|
||||||
|
<path d="M3 5C3 2.23858 5.23858 0 8 0C10.7614 0 13 2.23858 13 5V8L15 10V12H1V10L3 8V5Z" fill="currentColor"/>
|
||||||
|
<path d="M7.99999 16C6.69378 16 5.58254 15.1652 5.1707 14H10.8293C10.4175 15.1652 9.30621 16 7.99999 16Z" fill="currentColor"/>
|
||||||
|
</svg> Play
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)", margin: "0 2px" }} />
|
||||||
|
|
||||||
|
{/* Section 2: Cancel + Delete */}
|
||||||
<button
|
<button
|
||||||
onClick={() => navigate("/melodies/archetypes")}
|
onClick={() => navigate("/melodies/archetypes")}
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<button
|
<button onClick={() => setShowDelete(true)}
|
||||||
onClick={() => setShowDelete(true)}
|
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
|
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
|
||||||
>
|
|
||||||
Delete
|
Delete
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Divider */}
|
||||||
|
<div style={{ width: 1, height: 24, backgroundColor: "var(--border-primary)", margin: "0 2px" }} />
|
||||||
|
|
||||||
|
{/* Section 3: Save */}
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
|
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
>
|
>
|
||||||
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
|
{saving ? "Saving..." : isEdit ? "Rebuild and Save" : "Create and Build"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -260,6 +295,7 @@ export default function ArchetypeForm() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* ── Archetype Info ────────────────────────────────────────── */}
|
||||||
<section className="ui-section-card">
|
<section className="ui-section-card">
|
||||||
<div className="ui-section-card__title-row">
|
<div className="ui-section-card__title-row">
|
||||||
<h2 className="ui-section-card__title">Archetype Info</h2>
|
<h2 className="ui-section-card__title">Archetype Info</h2>
|
||||||
@@ -267,12 +303,14 @@ export default function ArchetypeForm() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-form-label">Name *</label>
|
<label className="ui-form-label">Name *</label>
|
||||||
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
|
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)}
|
||||||
|
placeholder="e.g. Doksologia_3k" className={inputClass} />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="ui-form-label">PID (Playback ID) *</label>
|
<label className="ui-form-label">PID (Playback ID) *</label>
|
||||||
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
|
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)}
|
||||||
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
|
placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
|
||||||
|
<p className="text-xs mt-1" style={mutedStyle}>Lowercase letters, numbers, and underscores only. Must be unique.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -294,13 +332,43 @@ export default function ArchetypeForm() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* ── Melodies using this Archetype ─────────────────────────── */}
|
||||||
{isEdit && (
|
{isEdit && (
|
||||||
<section className="ui-section-card">
|
<section className="ui-section-card">
|
||||||
<div className="ui-section-card__title-row">
|
<div className="ui-section-card__title-row">
|
||||||
<h2 className="ui-section-card__title">Build</h2>
|
<h2 className="ui-section-card__title">Melodies Using This Archetype</h2>
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded-full ml-2"
|
||||||
|
style={{ backgroundColor: "rgba(59,130,246,0.12)", color: "#60a5fa" }}>
|
||||||
|
{assignedCount}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm mb-4" style={mutedStyle}>
|
{loadingAssigned ? (
|
||||||
Save any changes above before building. Rebuilding will overwrite previous output.
|
<p className="text-sm" style={mutedStyle}>Loading...</p>
|
||||||
|
) : assignedMelodies.length === 0 ? (
|
||||||
|
<p className="text-sm" style={mutedStyle}>None</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1 max-h-44 overflow-y-auto">
|
||||||
|
{assignedMelodies.slice(0, 20).map((m) => (
|
||||||
|
<Link key={m.id} to={`/melodies/${m.id}`} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex items-center justify-between px-3 py-2 rounded-lg border transition-colors hover:bg-[var(--bg-card-hover)]"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-heading)", textDecoration: "none" }}>
|
||||||
|
<span className="text-sm font-medium">{getLocalizedValue(m.nameRaw, primaryLang, m.id)}</span>
|
||||||
|
<span className="text-xs" style={{ color: "var(--accent)" }}>Open →</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── Build Output ─────────────────────────────────────────── */}
|
||||||
|
{isEdit && (
|
||||||
|
<section className="ui-section-card">
|
||||||
|
<div className="ui-section-card__title-row">
|
||||||
|
<h2 className="ui-section-card__title">Build Output</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm mb-3" style={mutedStyle}>
|
||||||
|
Binary and PROGMEM code are auto-rebuilt on every save.
|
||||||
{hasUnsavedChanges && (
|
{hasUnsavedChanges && (
|
||||||
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> — You have unsaved changes.</span>
|
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> — You have unsaved changes.</span>
|
||||||
)}
|
)}
|
||||||
@@ -308,94 +376,64 @@ export default function ArchetypeForm() {
|
|||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Binary */}
|
{/* Binary */}
|
||||||
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
<div className="rounded-lg p-4 border space-y-2" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
|
||||||
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
|
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
|
||||||
</div>
|
</div>
|
||||||
{binaryBuilt && (
|
<span className={`px-2 py-0.5 text-xs rounded-full ${binaryBuilt ? "" : "opacity-50"}`}
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
style={binaryBuilt
|
||||||
Built
|
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||||
|
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
|
{binaryBuilt ? "Built" : "Not built"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleBuildBinary}
|
|
||||||
disabled={buildingBinary || hasUnsavedChanges}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
|
||||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
|
||||||
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
|
||||||
>
|
|
||||||
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
|
||||||
</button>
|
|
||||||
{binaryUrl && (
|
{binaryUrl && (
|
||||||
<button
|
<button type="button"
|
||||||
type="button"
|
|
||||||
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
|
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
|
||||||
className="block w-full text-center text-xs underline cursor-pointer"
|
className="block w-full text-center text-xs underline cursor-pointer"
|
||||||
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}>
|
||||||
>
|
|
||||||
Download {savedPid}.bsm
|
Download {savedPid}.bsm
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Builtin Code */}
|
{/* PROGMEM */}
|
||||||
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
<div className="rounded-lg p-4 border space-y-2" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
|
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
|
||||||
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
|
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
|
||||||
</div>
|
</div>
|
||||||
{progmemCode && (
|
<span className={`px-2 py-0.5 text-xs rounded-full ${progmemCode ? "" : "opacity-50"}`}
|
||||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
style={progmemCode
|
||||||
Generated
|
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||||
|
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||||
|
{progmemCode ? "Generated" : "Not generated"}
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={handleBuildBuiltin}
|
|
||||||
disabled={buildingBuiltin || hasUnsavedChanges}
|
|
||||||
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
|
||||||
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
|
||||||
>
|
|
||||||
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{progmemCode && (
|
{progmemCode && (
|
||||||
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
||||||
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
|
<div className="flex items-center justify-between px-4 py-2 border-b"
|
||||||
|
style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
|
||||||
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
|
||||||
PROGMEM C Code — copy into your firmware
|
PROGMEM C Code — copy into your firmware
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button onClick={handleCopy} className="text-xs px-3 py-1 rounded transition-colors"
|
||||||
onClick={handleCopy}
|
|
||||||
className="text-xs px-3 py-1 rounded transition-colors"
|
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
|
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
|
||||||
color: copied ? "var(--success-text)" : "var(--text-secondary)",
|
color: copied ? "var(--success-text)" : "var(--text-secondary)",
|
||||||
border: "1px solid var(--border-primary)",
|
border: "1px solid var(--border-primary)",
|
||||||
}}
|
}}>
|
||||||
>
|
|
||||||
{copied ? "Copied!" : "Copy"}
|
{copied ? "Copied!" : "Copy"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<pre
|
<pre ref={codeRef} className="p-4 text-xs overflow-x-auto"
|
||||||
ref={codeRef}
|
style={{ backgroundColor: "var(--bg-primary)", color: "var(--text-primary)", fontFamily: "monospace", whiteSpace: "pre", maxHeight: "400px", overflowY: "auto" }}>
|
||||||
className="p-4 text-xs overflow-x-auto"
|
|
||||||
style={{
|
|
||||||
backgroundColor: "var(--bg-primary)",
|
|
||||||
color: "var(--text-primary)",
|
|
||||||
fontFamily: "monospace",
|
|
||||||
whiteSpace: "pre",
|
|
||||||
maxHeight: "400px",
|
|
||||||
overflowY: "auto",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{progmemCode}
|
{progmemCode}
|
||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
@@ -405,7 +443,7 @@ export default function ArchetypeForm() {
|
|||||||
|
|
||||||
{!isEdit && (
|
{!isEdit && (
|
||||||
<div className="ui-section-card text-sm" style={{ color: "var(--text-muted)" }}>
|
<div className="ui-section-card text-sm" style={{ color: "var(--text-muted)" }}>
|
||||||
Build actions (Binary + PROGMEM Code) will be available after saving.
|
Binary and PROGMEM code will be generated automatically when you click "Create and Build".
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -413,27 +451,23 @@ export default function ArchetypeForm() {
|
|||||||
{/* Delete: two-stage if assigned */}
|
{/* Delete: two-stage if assigned */}
|
||||||
{showDelete && !deleteWarningConfirmed && assignedCount > 0 && (
|
{showDelete && !deleteWarningConfirmed && assignedCount > 0 && (
|
||||||
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
|
||||||
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
|
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
|
||||||
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
|
<div className="rounded-md p-3 border text-sm"
|
||||||
|
style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
|
||||||
<strong>"{name}"</strong> is currently assigned to{" "}
|
<strong>"{name}"</strong> is currently assigned to{" "}
|
||||||
<strong>{assignedCount} {assignedCount === 1 ? "melody" : "melodies"}</strong>.
|
<strong>{assignedCount} {assignedCount === 1 ? "melody" : "melodies"}</strong>.
|
||||||
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
|
Deleting it will flag those melodies as <strong>outdated</strong>.
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
|
||||||
onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
|
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>Cancel</button>
|
||||||
>
|
<button onClick={() => setDeleteWarningConfirmed(true)}
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteWarningConfirmed(true)}
|
|
||||||
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
|
||||||
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
|
style={{ backgroundColor: "var(--danger)", color: "#fff" }}>
|
||||||
>
|
|
||||||
Yes, Delete Anyway
|
Yes, Delete Anyway
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -448,6 +482,15 @@ export default function ArchetypeForm() {
|
|||||||
onConfirm={handleDelete}
|
onConfirm={handleDelete}
|
||||||
onCancel={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
|
onCancel={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<PlaybackModal
|
||||||
|
open={showPlayback}
|
||||||
|
melody={null}
|
||||||
|
builtMelody={playbackBuiltMelody}
|
||||||
|
files={null}
|
||||||
|
archetypeCsv={steps}
|
||||||
|
onClose={() => setShowPlayback(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -121,6 +121,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, curr
|
|||||||
archetype_csv: csv,
|
archetype_csv: csv,
|
||||||
steps: stepCount,
|
steps: stepCount,
|
||||||
totalNotes,
|
totalNotes,
|
||||||
|
outdated_archetype: false,
|
||||||
},
|
},
|
||||||
default_settings: currentMelody.default_settings,
|
default_settings: currentMelody.default_settings,
|
||||||
type: currentMelody.type,
|
type: currentMelody.type,
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function SelectArchetypeModal({ open, melodyId, currentMelody, cu
|
|||||||
archetype_csv: csv,
|
archetype_csv: csv,
|
||||||
steps,
|
steps,
|
||||||
totalNotes,
|
totalNotes,
|
||||||
|
outdated_archetype: false,
|
||||||
},
|
},
|
||||||
default_settings: currentMelody.default_settings,
|
default_settings: currentMelody.default_settings,
|
||||||
type: currentMelody.type,
|
type: currentMelody.type,
|
||||||
|
|||||||
133
frontend/src/settings/PublicFeaturesSettings.jsx
Normal file
133
frontend/src/settings/PublicFeaturesSettings.jsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
|
export default function PublicFeaturesSettings() {
|
||||||
|
const [settings, setSettings] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [success, setSuccess] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
api.get("/settings/public-features")
|
||||||
|
.then(setSettings)
|
||||||
|
.catch((e) => setError(e.message || "Failed to load settings."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToggle = async (key, value) => {
|
||||||
|
setError("");
|
||||||
|
setSuccess("");
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updated = await api.put("/settings/public-features", { [key]: value });
|
||||||
|
setSettings(updated);
|
||||||
|
setSuccess("Settings saved.");
|
||||||
|
setTimeout(() => setSuccess(""), 3000);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e.message || "Failed to save settings.");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Page header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
Public Features
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
Control which public-facing pages are accessible to end-users without requiring a login.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Error / success feedback */}
|
||||||
|
{error && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 border mb-4"
|
||||||
|
style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}
|
||||||
|
>
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{success && (
|
||||||
|
<div
|
||||||
|
className="text-sm rounded-md p-3 border mb-4"
|
||||||
|
style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}
|
||||||
|
>
|
||||||
|
{success}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading…</p>
|
||||||
|
) : settings ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<FeatureToggleRow
|
||||||
|
title="CloudFlash"
|
||||||
|
description="Allows end-users to flash their own devices via USB directly from the browser. When disabled, the CloudFlash page is inaccessible and displays a maintenance message."
|
||||||
|
enabled={settings.cloudflash_enabled}
|
||||||
|
saving={saving}
|
||||||
|
onToggle={(val) => handleToggle("cloudflash_enabled", val)}
|
||||||
|
url="/cloudflash"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeatureToggleRow({ title, description, enabled, saving, onToggle, url }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="rounded-lg border p-5"
|
||||||
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-6">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>
|
||||||
|
{title}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="px-2 py-0.5 text-xs rounded-full font-medium"
|
||||||
|
style={
|
||||||
|
enabled
|
||||||
|
? { backgroundColor: "var(--success-bg)", color: "var(--success-text)" }
|
||||||
|
: { backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{enabled ? "Live" : "Disabled"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
{url && (
|
||||||
|
<p className="text-xs mt-1.5 font-mono" style={{ color: "var(--text-link)" }}>
|
||||||
|
{url}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Toggle switch */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={saving}
|
||||||
|
onClick={() => onToggle(!enabled)}
|
||||||
|
className="flex-shrink-0 relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none disabled:opacity-50 cursor-pointer disabled:cursor-not-allowed"
|
||||||
|
style={{ backgroundColor: enabled ? "var(--accent)" : "var(--border-primary)" }}
|
||||||
|
aria-checked={enabled}
|
||||||
|
role="switch"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className="inline-block h-4 w-4 transform rounded-full bg-white transition-transform"
|
||||||
|
style={{ transform: enabled ? "translateX(20px)" : "translateX(4px)" }}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user