diff --git a/backend/manufacturing/service.py b/backend/manufacturing/service.py index 5cf29de..a7e5503 100644 --- a/backend/manufacturing/service.py +++ b/backend/manufacturing/service.py @@ -1,12 +1,15 @@ +import logging import random import string from datetime import datetime, timezone +logger = logging.getLogger(__name__) + from shared.firebase import get_db from shared.exceptions import NotFoundError from utils.serial_number import generate_serial from utils.nvs_generator import generate as generate_nvs_binary -from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem +from manufacturing.models import BatchCreate, BatchResponse, DeviceInventoryItem, DeviceStatusUpdate, DeviceAssign, ManufacturingStats, RecentActivityItem, BOARD_TYPE_LABELS COLLECTION = "devices" _BATCH_ID_CHARS = string.ascii_uppercase + string.digits @@ -162,6 +165,7 @@ def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem: if not docs: raise NotFoundError("Device") + doc_data = docs[0].to_dict() or {} doc_ref = docs[0].reference doc_ref.update({ "owner": data.customer_email, @@ -169,11 +173,18 @@ def assign_device(sn: str, data: DeviceAssign) -> DeviceInventoryItem: "mfg_status": "sold", }) - send_device_assignment_invite( - customer_email=data.customer_email, - serial_number=sn, - customer_name=data.customer_name, - ) + hw_type = doc_data.get("hw_type", "") + device_name = BOARD_TYPE_LABELS.get(hw_type, hw_type or "Device") + + try: + send_device_assignment_invite( + customer_email=data.customer_email, + serial_number=sn, + device_name=device_name, + customer_name=data.customer_name, + ) + except Exception as exc: + logger.error("Assignment succeeded but email failed for %s β†’ %s: %s", sn, data.customer_email, exc) return _doc_to_inventory_item(doc_ref.get()) diff --git a/backend/utils/email.py b/backend/utils/email.py index 7e3256d..b6fc6e5 100644 --- a/backend/utils/email.py +++ b/backend/utils/email.py @@ -5,15 +5,11 @@ from config import settings logger = logging.getLogger(__name__) -def _get_client() -> resend.Resend: - return resend.Resend(api_key=settings.resend_api_key) - - def send_email(to: str, subject: str, html: str) -> None: """Send a transactional email via Resend. Logs errors but does not raise.""" try: - client = _get_client() - client.emails.send({ + resend.api_key = settings.resend_api_key + resend.Emails.send({ "from": settings.email_from, "to": to, "subject": subject, @@ -28,29 +24,99 @@ def send_email(to: str, subject: str, html: str) -> None: def send_device_assignment_invite( customer_email: str, serial_number: str, + device_name: str, customer_name: str | None = None, ) -> None: - """Notify a customer that a Vesper device has been assigned to them.""" - greeting = f"Hi {customer_name}," if customer_name else "Hello," + """Notify a customer that a Bell Systems device has been assigned and shipped to them.""" + greeting = f"Dear {customer_name}," if customer_name else "Dear Customer," html = f""" -
-

Your Vesper device is ready

-

{greeting}

-

A Vesper bell automation device has been registered and assigned to you.

-

- Serial Number: - {serial_number} -

-

Open the Vesper app and enter this serial number to get started.

-
-

- If you did not expect this email, please contact your system administrator. -

-
+ + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+

BELLSYSTEMS

+

Device Shipment Confirmation

+
+

{greeting}

+ +

+ Your Bell Systems {device_name} device has been successfully manufactured and shipped. + We are delighted to have it on its way to you! +

+ +

+ To get started, download our controller application from the Google Play Store and follow the in-app setup instructions. +

+ + + + + + +
+ + Download on Google Play + +
+ + + + + + + + + +
+ Device
+ Bell Systems {device_name} +
+ Serial Number
+ {serial_number} +
+ +

+ Thank you very much. We greatly appreciate your choice in our products. +

+
+

BellSystems.gr

+

+ If you did not expect this email, please contact us at + support@bellsystems.gr +

+
+
+ + """ send_email( to=customer_email, - subject=f"Your Vesper device is ready β€” {serial_number}", + subject=f"Your Bell Systems {device_name} is on its way! πŸŽ‰", html=html, ) diff --git a/backend/utils/serial_number.py b/backend/utils/serial_number.py index b1e76aa..1b2df29 100644 --- a/backend/utils/serial_number.py +++ b/backend/utils/serial_number.py @@ -16,4 +16,5 @@ def generate_serial(board_type: str, board_version: str) -> str: month = MONTH_CODES[now.month - 1] day = now.strftime("%d") suffix = "".join(random.choices(SAFE_CHARS, k=5)) - return f"PV-{year}{month}{day}-{board_type.upper()}{board_version}R-{suffix}" + version_clean = board_version.replace(".", "") + return f"PV-{year}{month}{day}-{board_type.upper()}{version_clean}R-{suffix}" diff --git a/frontend/src/manufacturing/BatchCreator.jsx b/frontend/src/manufacturing/BatchCreator.jsx index 3ae7119..caea37a 100644 --- a/frontend/src/manufacturing/BatchCreator.jsx +++ b/frontend/src/manufacturing/BatchCreator.jsx @@ -3,15 +3,51 @@ import { useNavigate } from "react-router-dom"; import api from "../api/client"; const BOARD_TYPES = [ - { value: "vs", name: "VESPER", codename: "vesper-basic" }, - { value: "vp", name: "VESPER PLUS", codename: "vesper-plus" }, - { value: "vx", name: "VESPER PRO", codename: "vesper-pro" }, - { value: "cb", name: "CHRONOS", codename: "chronos-basic" }, - { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" }, - { value: "am", name: "AGNUS MINI", codename: "agnus-mini" }, - { value: "ab", name: "AGNUS", codename: "agnus-basic" }, + { value: "vx", name: "VESPER PRO", codename: "vesper-pro", desc: "Full-featured pro controller", family: "vesper" }, + { value: "vp", name: "VESPER PLUS", codename: "vesper-plus", desc: "Extended output controller", family: "vesper" }, + { value: "vs", name: "VESPER", codename: "vesper-basic", desc: "Standard bell controller", family: "vesper" }, + { value: "ab", name: "AGNUS", codename: "agnus-basic", desc: "Standard carillon module", family: "agnus" }, + { value: "am", name: "AGNUS MINI", codename: "agnus-mini", desc: "Compact carillon module", family: "agnus" }, + { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", desc: "Pro clock controller", family: "chronos" }, + { value: "cb", name: "CHRONOS", codename: "chronos-basic", desc: "Basic clock controller", family: "chronos" }, ]; +const BOARD_FAMILY_COLORS = { + vesper: { selectedBg: "#0a1929", selectedBorder: "#3b82f6", selectedText: "#60a5fa", hoverBorder: "#3b82f6", glowColor: "rgba(59,130,246,0.35)", idleBorder: "#1d3a5c", idleText: "#7ca8d4" }, + agnus: { selectedBg: "#1a1400", selectedBorder: "#f59e0b", selectedText: "#fbbf24", hoverBorder: "#f59e0b", glowColor: "rgba(245,158,11,0.35)", idleBorder: "#4a3800", idleText: "#c79d3a" }, + chronos: { selectedBg: "#1a0808", selectedBorder: "#ef4444", selectedText: "#f87171", hoverBorder: "#ef4444", glowColor: "rgba(239,68,68,0.35)", idleBorder: "#5c1a1a", idleText: "#d47a7a" }, +}; + +function BoardTile({ bt, isSelected, onClick }) { + const [hovered, setHovered] = useState(false); + const pal = BOARD_FAMILY_COLORS[bt.family]; + const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder; + const boxShadow = isSelected + ? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}` + : hovered ? `0 0 12px 3px ${pal.glowColor}` : "none"; + return ( + + ); +} + export default function BatchCreator() { const navigate = useNavigate(); const [boardType, setBoardType] = useState(null); @@ -57,7 +93,7 @@ export default function BatchCreator() { return (
-

+

New Batch

@@ -78,36 +114,34 @@ export default function BatchCreator() { )}
- {/* Board Type tiles */} + {/* Board Type tiles β€” Vesper: 3 col, Agnus: 2 col, Chronos: 2 col */}
-
- {BOARD_TYPES.map((bt) => { - const isSel = boardType === bt.value; - return ( - - ); - })} +
+ {/* Row 1: Vesper family */} +
+ {BOARD_TYPES.filter((b) => b.family === "vesper").map((bt) => ( + setBoardType(bt.value)} /> + ))} +
+ {/* Row 2: Agnus family β€” 2 cols left-aligned */} +
+
+ {BOARD_TYPES.filter((b) => b.family === "agnus").map((bt) => ( + setBoardType(bt.value)} /> + ))} +
+
+ {/* Row 3: Chronos family β€” 2 cols left-aligned */} +
+
+ {BOARD_TYPES.filter((b) => b.family === "chronos").map((bt) => ( + setBoardType(bt.value)} /> + ))} +
+
diff --git a/frontend/src/manufacturing/DeviceInventory.jsx b/frontend/src/manufacturing/DeviceInventory.jsx index 96c2b11..69be9c0 100644 --- a/frontend/src/manufacturing/DeviceInventory.jsx +++ b/frontend/src/manufacturing/DeviceInventory.jsx @@ -5,16 +5,23 @@ import api from "../api/client"; // ─── constants ──────────────────────────────────────────────────────────────── +// Row layout: Vesper Pro / Vesper Plus / Vesper | Agnus / Agnus Mini | Chronos Pro / Chronos const BOARD_TYPES = [ - { value: "vs", name: "VESPER", codename: "vesper-basic" }, - { value: "vp", name: "VESPER PLUS", codename: "vesper-plus" }, - { value: "vx", name: "VESPER PRO", codename: "vesper-pro" }, - { value: "cb", name: "CHRONOS", codename: "chronos-basic" }, - { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro" }, - { value: "am", name: "AGNUS MINI", codename: "agnus-mini" }, - { value: "ab", name: "AGNUS", codename: "agnus-basic" }, + { value: "vx", name: "VESPER PRO", codename: "vesper-pro", family: "vesper" }, + { value: "vp", name: "VESPER PLUS", codename: "vesper-plus", family: "vesper" }, + { value: "vs", name: "VESPER", codename: "vesper-basic", family: "vesper" }, + { value: "ab", name: "AGNUS", codename: "agnus-basic", family: "agnus" }, + { value: "am", name: "AGNUS MINI", codename: "agnus-mini", family: "agnus" }, + { value: "cp", name: "CHRONOS PRO", codename: "chronos-pro", family: "chronos" }, + { value: "cb", name: "CHRONOS", codename: "chronos-basic", family: "chronos" }, ]; +const BOARD_FAMILY_COLORS = { + vesper: { selectedBg: "#0a1929", selectedBorder: "#3b82f6", selectedText: "#60a5fa", hoverBorder: "#3b82f6", glowColor: "rgba(59,130,246,0.35)", idleBorder: "#1d3a5c", idleText: "#7ca8d4" }, + agnus: { selectedBg: "#1a1400", selectedBorder: "#f59e0b", selectedText: "#fbbf24", hoverBorder: "#f59e0b", glowColor: "rgba(245,158,11,0.35)", idleBorder: "#4a3800", idleText: "#c79d3a" }, + 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 STATUS_STYLES = { @@ -188,6 +195,35 @@ function ColumnToggle({ visible, orderedIds, onChange, onReorder }) { ); } +// ─── Board Type Tile ────────────────────────────────────────────────────────── + +function BoardTypeTile({ bt, isSelected, pal, onClick }) { + const [hovered, setHovered] = useState(false); + const borderColor = isSelected ? pal.selectedBorder : hovered ? pal.hoverBorder : pal.idleBorder; + const boxShadow = isSelected + ? `0 0 0 1px ${pal.selectedBorder}, 0 0 14px 4px ${pal.glowColor}` + : hovered ? `0 0 12px 3px ${pal.glowColor}` : "none"; + return ( + + ); +} + // ─── Add Device Modal ───────────────────────────────────────────────────────── function AddDeviceModal({ onClose, onCreated }) { @@ -222,26 +258,29 @@ function AddDeviceModal({ onClose, onCreated }) {

Add Single Device

Board Type

-
- {BOARD_TYPES.map((bt) => { - const isSel = boardType === bt.value; - return ( - - ); - })} +
+ {/* Row 1: Vesper family β€” 3 columns */} +
+ {BOARD_TYPES.filter((b) => b.family === "vesper").map((bt) => ( + setBoardType(bt.value)} /> + ))} +
+ {/* Row 2: Agnus family β€” 2 columns (left-aligned in 3-col grid) */} +
+
+ {BOARD_TYPES.filter((b) => b.family === "agnus").map((bt) => ( + setBoardType(bt.value)} /> + ))} +
+
+ {/* Row 3: Chronos family β€” 2 columns (left-aligned in 3-col grid) */} +
+
+ {BOARD_TYPES.filter((b) => b.family === "chronos").map((bt) => ( + setBoardType(bt.value)} /> + ))} +
+