From 337712ffacb4d0c42e455554f414c6269d27f913 Mon Sep 17 00:00:00 2001 From: bonamin Date: Tue, 17 Feb 2026 14:05:39 +0200 Subject: [PATCH] Phase 3 Complete by Claude Code --- backend/devices/models.py | 154 ++++- backend/devices/router.py | 59 +- backend/devices/service.py | 144 +++- backend/main.py | 2 + backend/mqtt/mosquitto.py | 53 +- bellsystemsblack-300x138_lightTheme.png | Bin 0 -> 14278 bytes frontend/src/App.jsx | 8 +- frontend/src/devices/DeviceDetail.jsx | 314 ++++++++- frontend/src/devices/DeviceForm.jsx | 882 +++++++++++++++++++++++- frontend/src/devices/DeviceList.jsx | 210 +++++- frontend/src/melodies/MelodyDetail.jsx | 5 - 11 files changed, 1818 insertions(+), 13 deletions(-) create mode 100644 bellsystemsblack-300x138_lightTheme.png diff --git a/backend/devices/models.py b/backend/devices/models.py index f7edf46..4e5634f 100644 --- a/backend/devices/models.py +++ b/backend/devices/models.py @@ -1 +1,153 @@ -# TODO: Device Pydantic schemas +from pydantic import BaseModel, Field +from typing import List, Optional +from enum import Enum + + +# --- Enums --- + +class HelperMelodyTypes(str, Enum): + orthodox = "orthodox" + catholic = "catholic" + all = "all" + + +class HelperRingAlerts(str, Enum): + disabled = "disabled" + single = "single" + multi = "multi" + + +class DeviceTiers(str, Enum): + basic = "basic" + small = "small" + mini = "mini" + premium = "premium" + vip = "vip" + custom = "custom" + + +# --- Nested Structs --- + +class DeviceNetworkSettings(BaseModel): + hostname: str = "" + useStaticIP: bool = False + ipAddress: List[str] = [] + gateway: List[str] = [] + subnet: List[str] = [] + dns1: List[str] = [] + dns2: List[str] = [] + + +class DeviceClockSettings(BaseModel): + clockOutputs: List[int] = [] + clockTimings: List[int] = [] + ringAlertsMasterOn: bool = False + ringAlerts: HelperRingAlerts = HelperRingAlerts.disabled + ringIntervals: int = 0 + hourAlertsBell: int = 0 + halfhourAlertsBell: int = 0 + quarterAlertsBell: int = 0 + isDaySilenceOn: bool = False + isNightSilenceOn: bool = False + daySilenceFrom: str = "" + daySilenceTo: str = "" + nightSilenceFrom: str = "" + nightSilenceTo: str = "" + backlightTurnOnTime: str = "" + backlightTurnOffTime: str = "" + isBacklightAutomationOn: bool = False + backlightOutput: int = 0 + + +class DeviceAttributes(BaseModel): + hasAssistant: bool = False + hasClock: bool = False + hasBells: bool = False + totalBells: int = 0 + bellOutputs: List[int] = [] + hammerTimings: List[int] = [] + bellGuardOn: bool = False + bellGuardSafetyOn: bool = False + warningsOn: bool = False + towerClockTime: str = "" + clockSettings: DeviceClockSettings = DeviceClockSettings() + deviceLocale: HelperMelodyTypes = HelperMelodyTypes.all + networkSettings: DeviceNetworkSettings = DeviceNetworkSettings() + serialLogLevel: int = 0 + sdLogLevel: int = 0 + + +class DeviceSubInformation(BaseModel): + subscrTier: DeviceTiers = DeviceTiers.basic + subscrStart: str = "" + subscrDuration: int = 0 + maxUsers: int = 0 + maxOutputs: int = 0 + + +class DeviceStatistics(BaseModel): + totalPlaybacks: int = 0 + totalHammerStrikes: int = 0 + perBellStrikes: List[int] = [] + totalWarningsGiven: int = 0 + warrantyActive: bool = False + warrantyStart: str = "" + warrantyPeriod: int = 0 + maintainedOn: str = "" + maintainancePeriod: int = 0 + + +class MelodyMainItem(BaseModel): + """Mirrors the Melody schema used in the melodies collection.""" + information: dict = {} + default_settings: dict = {} + type: str = "" + url: str = "" + uid: str = "" + pid: str = "" + + +# --- Request / Response schemas --- + +class DeviceCreate(BaseModel): + device_name: str = "" + device_photo: str = "" + device_location: str = "" + is_Online: bool = False + device_attributes: DeviceAttributes = DeviceAttributes() + device_subscription: DeviceSubInformation = DeviceSubInformation() + device_stats: DeviceStatistics = DeviceStatistics() + events_on: bool = False + device_location_coordinates: str = "" + device_melodies_all: List[MelodyMainItem] = [] + device_melodies_favorites: List[str] = [] + user_list: List[str] = [] + websocket_url: str = "" + churchAssistantURL: str = "" + + +class DeviceUpdate(BaseModel): + device_name: Optional[str] = None + device_photo: Optional[str] = None + device_location: Optional[str] = None + is_Online: Optional[bool] = None + device_attributes: Optional[DeviceAttributes] = None + device_subscription: Optional[DeviceSubInformation] = None + device_stats: Optional[DeviceStatistics] = None + events_on: Optional[bool] = None + device_location_coordinates: Optional[str] = None + device_melodies_all: Optional[List[MelodyMainItem]] = None + device_melodies_favorites: Optional[List[str]] = None + user_list: Optional[List[str]] = None + websocket_url: Optional[str] = None + churchAssistantURL: Optional[str] = None + + +class DeviceInDB(DeviceCreate): + id: str + device_id: str = "" + + +class DeviceListResponse(BaseModel): + devices: List[DeviceInDB] + total: int diff --git a/backend/devices/router.py b/backend/devices/router.py index 31bc220..2886fbb 100644 --- a/backend/devices/router.py +++ b/backend/devices/router.py @@ -1 +1,58 @@ -# TODO: CRUD endpoints for devices +from fastapi import APIRouter, Depends, Query +from typing import Optional +from auth.models import TokenPayload +from auth.dependencies import require_device_access, require_viewer +from devices.models import ( + DeviceCreate, DeviceUpdate, DeviceInDB, DeviceListResponse, +) +from devices import service + +router = APIRouter(prefix="/api/devices", tags=["devices"]) + + +@router.get("", response_model=DeviceListResponse) +async def list_devices( + search: Optional[str] = Query(None), + online: Optional[bool] = Query(None), + tier: Optional[str] = Query(None), + _user: TokenPayload = Depends(require_viewer), +): + devices = service.list_devices( + search=search, + online_only=online, + subscription_tier=tier, + ) + return DeviceListResponse(devices=devices, total=len(devices)) + + +@router.get("/{device_id}", response_model=DeviceInDB) +async def get_device( + device_id: str, + _user: TokenPayload = Depends(require_viewer), +): + return service.get_device(device_id) + + +@router.post("", response_model=DeviceInDB, status_code=201) +async def create_device( + body: DeviceCreate, + _user: TokenPayload = Depends(require_device_access), +): + return service.create_device(body) + + +@router.put("/{device_id}", response_model=DeviceInDB) +async def update_device( + device_id: str, + body: DeviceUpdate, + _user: TokenPayload = Depends(require_device_access), +): + return service.update_device(device_id, body) + + +@router.delete("/{device_id}", status_code=204) +async def delete_device( + device_id: str, + _user: TokenPayload = Depends(require_device_access), +): + service.delete_device(device_id) diff --git a/backend/devices/service.py b/backend/devices/service.py index 461b7c0..f8de946 100644 --- a/backend/devices/service.py +++ b/backend/devices/service.py @@ -1 +1,143 @@ -# TODO: Device Firestore operations +import secrets +import string + +from shared.firebase import get_db +from shared.exceptions import NotFoundError +from devices.models import DeviceCreate, DeviceUpdate, DeviceInDB +from mqtt.mosquitto import register_device_password + +COLLECTION = "devices" + +# Serial number format: BS-XXXX-XXXX (uppercase alphanumeric) +SN_CHARS = string.ascii_uppercase + string.digits +SN_SEGMENT_LEN = 4 + + +def _generate_serial_number() -> str: + """Generate a unique serial number in the format BS-XXXX-XXXX.""" + seg1 = "".join(secrets.choice(SN_CHARS) for _ in range(SN_SEGMENT_LEN)) + seg2 = "".join(secrets.choice(SN_CHARS) for _ in range(SN_SEGMENT_LEN)) + return f"BS-{seg1}-{seg2}" + + +def _ensure_unique_serial(db) -> str: + """Generate a serial number and verify it doesn't already exist in Firestore.""" + existing_sns = set() + for doc in db.collection(COLLECTION).select(["device_id"]).stream(): + data = doc.to_dict() + if data.get("device_id"): + existing_sns.add(data["device_id"]) + + for _ in range(100): # safety limit + sn = _generate_serial_number() + if sn not in existing_sns: + return sn + + raise RuntimeError("Could not generate a unique serial number after 100 attempts") + + +def _doc_to_device(doc) -> DeviceInDB: + """Convert a Firestore document snapshot to a DeviceInDB model.""" + data = doc.to_dict() + return DeviceInDB(id=doc.id, **data) + + +def list_devices( + search: str | None = None, + online_only: bool | None = None, + subscription_tier: str | None = None, +) -> list[DeviceInDB]: + """List devices with optional filters.""" + db = get_db() + ref = db.collection(COLLECTION) + query = ref + + if subscription_tier: + query = query.where("device_subscription.subscrTier", "==", subscription_tier) + + docs = query.stream() + results = [] + + for doc in docs: + device = _doc_to_device(doc) + + # Client-side filters + if online_only is not None and device.is_Online != online_only: + continue + + if search: + search_lower = search.lower() + name_match = search_lower in (device.device_name or "").lower() + location_match = search_lower in (device.device_location or "").lower() + sn_match = search_lower in (device.device_id or "").lower() + if not (name_match or location_match or sn_match): + continue + + results.append(device) + + return results + + +def get_device(device_doc_id: str) -> DeviceInDB: + """Get a single device by Firestore document ID.""" + db = get_db() + doc = db.collection(COLLECTION).document(device_doc_id).get() + if not doc.exists: + raise NotFoundError("Device") + return _doc_to_device(doc) + + +def create_device(data: DeviceCreate) -> DeviceInDB: + """Create a new device document in Firestore with auto-generated serial number.""" + db = get_db() + + # Generate unique serial number + serial_number = _ensure_unique_serial(db) + + # Generate MQTT password and register with Mosquitto + mqtt_password = secrets.token_urlsafe(24) + register_device_password(serial_number, mqtt_password) + + doc_data = data.model_dump() + doc_data["device_id"] = serial_number + + _, doc_ref = db.collection(COLLECTION).add(doc_data) + + return DeviceInDB(id=doc_ref.id, **doc_data) + + +def update_device(device_doc_id: str, data: DeviceUpdate) -> DeviceInDB: + """Update an existing device document. Only provided fields are updated.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(device_doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Device") + + update_data = data.model_dump(exclude_none=True) + + # For nested structs, merge with existing data rather than replacing + existing = doc.to_dict() + nested_keys = ( + "device_attributes", "device_subscription", "device_stats", + ) + for key in nested_keys: + if key in update_data and key in existing: + merged = {**existing[key], **update_data[key]} + update_data[key] = merged + + doc_ref.update(update_data) + + updated_doc = doc_ref.get() + return _doc_to_device(updated_doc) + + +def delete_device(device_doc_id: str) -> None: + """Delete a device document from Firestore.""" + db = get_db() + doc_ref = db.collection(COLLECTION).document(device_doc_id) + doc = doc_ref.get() + if not doc.exists: + raise NotFoundError("Device") + + doc_ref.delete() diff --git a/backend/main.py b/backend/main.py index 625da28..1cb2650 100644 --- a/backend/main.py +++ b/backend/main.py @@ -4,6 +4,7 @@ from config import settings from shared.firebase import init_firebase, firebase_initialized from auth.router import router as auth_router from melodies.router import router as melodies_router +from devices.router import router as devices_router from settings.router import router as settings_router app = FastAPI( @@ -23,6 +24,7 @@ app.add_middleware( app.include_router(auth_router) app.include_router(melodies_router) +app.include_router(devices_router) app.include_router(settings_router) diff --git a/backend/mqtt/mosquitto.py b/backend/mqtt/mosquitto.py index d7d018e..3a9e9f7 100644 --- a/backend/mqtt/mosquitto.py +++ b/backend/mqtt/mosquitto.py @@ -1 +1,52 @@ -# TODO: Mosquitto password file management +import subprocess +import os +from config import settings + + +def register_device_password(serial_number: str, password: str) -> bool: + """Register a device in the Mosquitto password file. + + Uses mosquitto_passwd to add/update the device credentials. + The serial number is used as the MQTT username. + Returns True on success, False on failure. + """ + passwd_file = settings.mosquitto_password_file + + # Ensure the password file exists + if not os.path.exists(passwd_file): + # Create the file if it doesn't exist + os.makedirs(os.path.dirname(passwd_file), exist_ok=True) + open(passwd_file, "a").close() + + try: + # Use mosquitto_passwd with -b flag (batch mode) to set password + result = subprocess.run( + ["mosquitto_passwd", "-b", passwd_file, serial_number, password], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + print(f"[WARNING] Mosquitto password registration failed: {e}") + return False + + +def remove_device_password(serial_number: str) -> bool: + """Remove a device from the Mosquitto password file.""" + passwd_file = settings.mosquitto_password_file + + if not os.path.exists(passwd_file): + return True + + try: + result = subprocess.run( + ["mosquitto_passwd", "-D", passwd_file, serial_number], + capture_output=True, + text=True, + timeout=10, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, FileNotFoundError) as e: + print(f"[WARNING] Mosquitto password removal failed: {e}") + return False diff --git a/bellsystemsblack-300x138_lightTheme.png b/bellsystemsblack-300x138_lightTheme.png new file mode 100644 index 0000000000000000000000000000000000000000..378e9f698ea75e5d52ed217254e945483d1ed22f GIT binary patch literal 14278 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 literal 0 HcmV?d00001 diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 03c4edb..b5533d5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -6,6 +6,9 @@ import MelodyList from "./melodies/MelodyList"; import MelodyDetail from "./melodies/MelodyDetail"; import MelodyForm from "./melodies/MelodyForm"; import MelodySettings from "./melodies/MelodySettings"; +import DeviceList from "./devices/DeviceList"; +import DeviceDetail from "./devices/DeviceDetail"; +import DeviceForm from "./devices/DeviceForm"; function ProtectedRoute({ children }) { const { user, loading } = useAuth(); @@ -55,8 +58,11 @@ export default function App() { } /> } /> } /> - {/* Phase 3+ routes: } /> + } /> + } /> + } /> + {/* Phase 4+ routes: } /> } /> */} diff --git a/frontend/src/devices/DeviceDetail.jsx b/frontend/src/devices/DeviceDetail.jsx index d396dc1..e7d92ed 100644 --- a/frontend/src/devices/DeviceDetail.jsx +++ b/frontend/src/devices/DeviceDetail.jsx @@ -1 +1,313 @@ -// TODO: Device detail view +import { useState, useEffect } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import ConfirmDialog from "../components/ConfirmDialog"; + +function Field({ label, children }) { + return ( +
+
+ {label} +
+
{children || "-"}
+
+ ); +} + +function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) { + return ( + + {value ? yesLabel : noLabel} + + ); +} + +export default function DeviceDetail() { + const { id } = useParams(); + const navigate = useNavigate(); + const { hasRole } = useAuth(); + const canEdit = hasRole("superadmin", "device_manager"); + + const [device, setDevice] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [showDelete, setShowDelete] = useState(false); + + useEffect(() => { + loadData(); + }, [id]); + + const loadData = async () => { + setLoading(true); + try { + const d = await api.get(`/devices/${id}`); + setDevice(d); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + try { + await api.delete(`/devices/${id}`); + navigate("/devices"); + } catch (err) { + setError(err.message); + setShowDelete(false); + } + }; + + if (loading) { + return
Loading...
; + } + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!device) return null; + + const attr = device.device_attributes || {}; + const clock = attr.clockSettings || {}; + const net = attr.networkSettings || {}; + const sub = device.device_subscription || {}; + const stats = device.device_stats || {}; + + return ( +
+
+
+ +
+

+ {device.device_name || "Unnamed Device"} +

+ +
+
+ {canEdit && ( +
+ + +
+ )} +
+ +
+ {/* Left column */} +
+ {/* Basic Info */} +
+

+ Basic Information +

+
+ + {device.device_id} + + + {device.id} + + + + +
+ {device.device_location} +
+ {device.device_location_coordinates} + + + + {device.websocket_url} +
+ {device.churchAssistantURL} +
+
+
+ + {/* Device Attributes */} +
+

+ Device Attributes +

+
+ + + + {attr.totalBells} + + + + + {attr.deviceLocale} + + {attr.serialLogLevel} + {attr.sdLogLevel} + + {attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"} + + + {attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"} + +
+
+ + {/* Network */} +
+

+ Network Settings +

+
+ {net.hostname} + +
+
+
+ + {/* Right column */} +
+ {/* Subscription */} +
+

+ Subscription +

+
+ + + {sub.subscrTier} + + + {sub.subscrStart} + {sub.subscrDuration} months + {sub.maxUsers} + {sub.maxOutputs} +
+
+ + {/* Clock Settings */} +
+

+ Clock Settings +

+
+ + + {clock.ringAlerts} + + {clock.ringIntervals} + {clock.hourAlertsBell} + {clock.halfhourAlertsBell} + {clock.quarterAlertsBell} + {clock.backlightOutput} + + + {clock.clockOutputs?.length > 0 ? clock.clockOutputs.join(", ") : "-"} + + + {clock.clockTimings?.length > 0 ? clock.clockTimings.join(", ") : "-"} + +
+ {(clock.isDaySilenceOn || clock.isNightSilenceOn) && ( +
+

Silence Periods

+
+ {clock.isDaySilenceOn && ( + <> + + {clock.daySilenceFrom} - {clock.daySilenceTo} + + + )} + {clock.isNightSilenceOn && ( + <> + + {clock.nightSilenceFrom} - {clock.nightSilenceTo} + + + )} +
+
+ )} +
+ + {/* Statistics */} +
+

+ Statistics & Warranty +

+
+ {stats.totalPlaybacks} + {stats.totalHammerStrikes} + {stats.totalWarningsGiven} + + {stats.perBellStrikes?.length > 0 ? stats.perBellStrikes.join(", ") : "-"} + + + {stats.warrantyStart} + {stats.warrantyPeriod} months + {stats.maintainedOn} + {stats.maintainancePeriod} months +
+
+ + {/* Melodies & Users summary */} +
+

+ Melodies & Users +

+
+ + {device.device_melodies_all?.length ?? 0} + + + {device.device_melodies_favorites?.length ?? 0} + + + {device.user_list?.length ?? 0} + +
+
+
+
+ + setShowDelete(false)} + /> +
+ ); +} diff --git a/frontend/src/devices/DeviceForm.jsx b/frontend/src/devices/DeviceForm.jsx index b6eb6b1..723f73c 100644 --- a/frontend/src/devices/DeviceForm.jsx +++ b/frontend/src/devices/DeviceForm.jsx @@ -1 +1,881 @@ -// TODO: Add / Edit device form +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import api from "../api/client"; + +const TIER_OPTIONS = ["basic", "small", "mini", "premium", "vip", "custom"]; +const LOCALE_OPTIONS = ["orthodox", "catholic", "all"]; +const RING_ALERT_OPTIONS = ["disabled", "single", "multi"]; + +const defaultAttributes = { + hasAssistant: false, + hasClock: false, + hasBells: false, + totalBells: 0, + bellOutputs: [], + hammerTimings: [], + bellGuardOn: false, + bellGuardSafetyOn: false, + warningsOn: false, + towerClockTime: "", + clockSettings: { + clockOutputs: [], + clockTimings: [], + ringAlertsMasterOn: false, + ringAlerts: "disabled", + ringIntervals: 0, + hourAlertsBell: 0, + halfhourAlertsBell: 0, + quarterAlertsBell: 0, + isDaySilenceOn: false, + isNightSilenceOn: false, + daySilenceFrom: "", + daySilenceTo: "", + nightSilenceFrom: "", + nightSilenceTo: "", + backlightTurnOnTime: "", + backlightTurnOffTime: "", + isBacklightAutomationOn: false, + backlightOutput: 0, + }, + deviceLocale: "all", + networkSettings: { + hostname: "", + useStaticIP: false, + ipAddress: [], + gateway: [], + subnet: [], + dns1: [], + dns2: [], + }, + serialLogLevel: 0, + sdLogLevel: 0, +}; + +const defaultSubscription = { + subscrTier: "basic", + subscrStart: "", + subscrDuration: 0, + maxUsers: 0, + maxOutputs: 0, +}; + +const defaultStats = { + totalPlaybacks: 0, + totalHammerStrikes: 0, + perBellStrikes: [], + totalWarningsGiven: 0, + warrantyActive: false, + warrantyStart: "", + warrantyPeriod: 0, + maintainedOn: "", + maintainancePeriod: 0, +}; + +const parseIntList = (str) => { + if (!str.trim()) return []; + return str + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !isNaN(n)); +}; + +export default function DeviceForm() { + const { id } = useParams(); + const isEdit = Boolean(id); + const navigate = useNavigate(); + + const [deviceName, setDeviceName] = useState(""); + const [devicePhoto, setDevicePhoto] = useState(""); + const [deviceLocation, setDeviceLocation] = useState(""); + const [isOnline, setIsOnline] = useState(false); + const [eventsOn, setEventsOn] = useState(false); + const [locationCoordinates, setLocationCoordinates] = useState(""); + const [websocketUrl, setWebsocketUrl] = useState(""); + const [churchAssistantURL, setChurchAssistantURL] = useState(""); + + const [attributes, setAttributes] = useState({ ...defaultAttributes, clockSettings: { ...defaultAttributes.clockSettings }, networkSettings: { ...defaultAttributes.networkSettings } }); + const [subscription, setSubscription] = useState({ ...defaultSubscription }); + const [stats, setStats] = useState({ ...defaultStats }); + + const [loading, setLoading] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(""); + + useEffect(() => { + if (isEdit) loadDevice(); + }, [id]); + + const loadDevice = async () => { + setLoading(true); + try { + const device = await api.get(`/devices/${id}`); + setDeviceName(device.device_name || ""); + setDevicePhoto(device.device_photo || ""); + setDeviceLocation(device.device_location || ""); + setIsOnline(device.is_Online || false); + setEventsOn(device.events_on || false); + setLocationCoordinates(device.device_location_coordinates || ""); + setWebsocketUrl(device.websocket_url || ""); + setChurchAssistantURL(device.churchAssistantURL || ""); + + setAttributes({ + ...defaultAttributes, + ...device.device_attributes, + clockSettings: { + ...defaultAttributes.clockSettings, + ...(device.device_attributes?.clockSettings || {}), + }, + networkSettings: { + ...defaultAttributes.networkSettings, + ...(device.device_attributes?.networkSettings || {}), + }, + }); + setSubscription({ ...defaultSubscription, ...(device.device_subscription || {}) }); + setStats({ ...defaultStats, ...(device.device_stats || {}) }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + const updateAttr = (field, value) => + setAttributes((prev) => ({ ...prev, [field]: value })); + + const updateClock = (field, value) => + setAttributes((prev) => ({ + ...prev, + clockSettings: { ...prev.clockSettings, [field]: value }, + })); + + const updateNetwork = (field, value) => + setAttributes((prev) => ({ + ...prev, + networkSettings: { ...prev.networkSettings, [field]: value }, + })); + + const updateSub = (field, value) => + setSubscription((prev) => ({ ...prev, [field]: value })); + + const updateStats = (field, value) => + setStats((prev) => ({ ...prev, [field]: value })); + + const handleSubmit = async (e) => { + e.preventDefault(); + setSaving(true); + setError(""); + + try { + const body = { + device_name: deviceName, + device_photo: devicePhoto, + device_location: deviceLocation, + is_Online: isOnline, + device_attributes: attributes, + device_subscription: subscription, + device_stats: stats, + events_on: eventsOn, + device_location_coordinates: locationCoordinates, + device_melodies_all: [], + device_melodies_favorites: [], + user_list: [], + websocket_url: websocketUrl, + churchAssistantURL, + }; + + let deviceId = id; + if (isEdit) { + await api.put(`/devices/${id}`, body); + } else { + const created = await api.post("/devices", body); + deviceId = created.id; + } + + navigate(`/devices/${deviceId}`); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + }; + + if (loading) { + return
Loading...
; + } + + const inputClass = + "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"; + + return ( +
+

+ {isEdit ? "Edit Device" : "Add Device"} +

+ + {error && ( +
+ {error} +
+ )} + +
+
+ {/* ===== Left Column ===== */} +
+ {/* --- Basic Info --- */} +
+

+ Basic Information +

+
+
+ + setDeviceName(e.target.value)} + className={inputClass} + /> +
+
+ + setDeviceLocation(e.target.value)} + placeholder="e.g. St. Mary's Church, Vienna" + className={inputClass} + /> +
+
+ + setLocationCoordinates(e.target.value)} + placeholder="e.g. 48.2082,16.3738" + className={inputClass} + /> +
+
+ + setDevicePhoto(e.target.value)} + className={inputClass} + /> +
+
+ +
+
+
+ + {/* --- Device Attributes --- */} +
+

+ Device Attributes +

+
+
+ + + + + + +
+
+ + updateAttr("totalBells", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + +
+
+ + updateAttr("bellOutputs", parseIntList(e.target.value))} + placeholder="e.g. 1, 2, 3" + className={inputClass} + /> +
+
+ + updateAttr("hammerTimings", parseIntList(e.target.value))} + placeholder="e.g. 100, 150, 200" + className={inputClass} + /> +
+
+ + updateAttr("serialLogLevel", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateAttr("sdLogLevel", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+
+ + {/* --- Network Settings --- */} +
+

+ Network Settings +

+
+
+ + updateNetwork("hostname", e.target.value)} + className={inputClass} + /> +
+
+ updateNetwork("useStaticIP", e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + +
+
+ + setWebsocketUrl(e.target.value)} + className={inputClass} + /> +
+
+ + setChurchAssistantURL(e.target.value)} + className={inputClass} + /> +
+
+
+
+ + {/* ===== Right Column ===== */} +
+ {/* --- Subscription --- */} +
+

+ Subscription +

+
+
+ + +
+
+ + updateSub("subscrStart", e.target.value)} + className={inputClass} + /> +
+
+ + updateSub("subscrDuration", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateSub("maxUsers", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateSub("maxOutputs", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+
+ + {/* --- Clock Settings --- */} +
+

+ Clock Settings +

+
+
+ + +
+
+ + updateClock("ringIntervals", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ updateClock("ringAlertsMasterOn", e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + Ring Alerts Master On +
+
+ + updateClock("clockOutputs", parseIntList(e.target.value))} + className={inputClass} + /> +
+
+ + updateClock("clockTimings", parseIntList(e.target.value))} + className={inputClass} + /> +
+
+ + updateClock("hourAlertsBell", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateClock("halfhourAlertsBell", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateClock("quarterAlertsBell", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateClock("backlightOutput", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ updateClock("isBacklightAutomationOn", e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + Backlight Automation +
+ + {/* Silence settings */} +
+

Silence Periods

+
+
+ updateClock("isDaySilenceOn", e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + Day Silence +
+
+ updateClock("isNightSilenceOn", e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + Night Silence +
+
+ + updateClock("daySilenceFrom", e.target.value)} + className={inputClass} + /> +
+
+ + updateClock("daySilenceTo", e.target.value)} + className={inputClass} + /> +
+
+ + updateClock("nightSilenceFrom", e.target.value)} + className={inputClass} + /> +
+
+ + updateClock("nightSilenceTo", e.target.value)} + className={inputClass} + /> +
+
+
+
+
+ + {/* --- Statistics --- */} +
+

+ Statistics & Warranty +

+
+
+ + updateStats("totalPlaybacks", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateStats("totalHammerStrikes", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateStats("totalWarningsGiven", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateStats("perBellStrikes", parseIntList(e.target.value))} + className={inputClass} + /> +
+
+ updateStats("warrantyActive", e.target.checked)} + className="h-4 w-4 text-blue-600 rounded border-gray-300" + /> + Warranty Active +
+
+ + updateStats("warrantyStart", e.target.value)} + className={inputClass} + /> +
+
+ + updateStats("warrantyPeriod", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+ + updateStats("maintainedOn", e.target.value)} + className={inputClass} + /> +
+
+ + updateStats("maintainancePeriod", parseInt(e.target.value, 10) || 0)} + className={inputClass} + /> +
+
+
+
+
+ + {/* --- Actions --- */} +
+ + +
+
+
+ ); +} diff --git a/frontend/src/devices/DeviceList.jsx b/frontend/src/devices/DeviceList.jsx index 8208299..cbbed24 100644 --- a/frontend/src/devices/DeviceList.jsx +++ b/frontend/src/devices/DeviceList.jsx @@ -1 +1,209 @@ -// TODO: Device list component +import { useState, useEffect } from "react"; +import { useNavigate } from "react-router-dom"; +import api from "../api/client"; +import { useAuth } from "../auth/AuthContext"; +import SearchBar from "../components/SearchBar"; +import ConfirmDialog from "../components/ConfirmDialog"; + +const TIER_OPTIONS = ["", "basic", "small", "mini", "premium", "vip", "custom"]; + +export default function DeviceList() { + const [devices, setDevices] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [search, setSearch] = useState(""); + const [onlineFilter, setOnlineFilter] = useState(""); + const [tierFilter, setTierFilter] = useState(""); + const [deleteTarget, setDeleteTarget] = useState(null); + const navigate = useNavigate(); + const { hasRole } = useAuth(); + const canEdit = hasRole("superadmin", "device_manager"); + + const fetchDevices = async () => { + setLoading(true); + setError(""); + try { + const params = new URLSearchParams(); + if (search) params.set("search", search); + if (onlineFilter === "true") params.set("online", "true"); + if (onlineFilter === "false") params.set("online", "false"); + if (tierFilter) params.set("tier", tierFilter); + const qs = params.toString(); + const data = await api.get(`/devices${qs ? `?${qs}` : ""}`); + setDevices(data.devices); + setTotal(data.total); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchDevices(); + }, [search, onlineFilter, tierFilter]); + + const handleDelete = async () => { + if (!deleteTarget) return; + try { + await api.delete(`/devices/${deleteTarget.id}`); + setDeleteTarget(null); + fetchDevices(); + } catch (err) { + setError(err.message); + setDeleteTarget(null); + } + }; + + return ( +
+
+

Devices

+ {canEdit && ( + + )} +
+ +
+ +
+ + + + {total} {total === 1 ? "device" : "devices"} + +
+
+ + {error && ( +
+ {error} +
+ )} + + {loading ? ( +
Loading...
+ ) : devices.length === 0 ? ( +
+ No devices found. +
+ ) : ( +
+
+ + + + + + + + + + + {canEdit && ( + + + + {devices.map((device) => ( + navigate(`/devices/${device.id}`)} + className="border-b border-gray-100 last:border-0 cursor-pointer hover:bg-gray-50" + > + + + + + + + + {canEdit && ( + + )} + + ))} + +
StatusNameSerial NumberLocationTierBellsUsers + )} +
+ + + {device.device_name || "Unnamed Device"} + + {device.device_id || "-"} + + {device.device_location || "-"} + + + {device.device_subscription?.subscrTier || "basic"} + + + {device.device_attributes?.totalBells ?? 0} + + {device.user_list?.length ?? 0} + +
e.stopPropagation()} + > + + +
+
+
+
+ )} + + setDeleteTarget(null)} + /> +
+ ); +} diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 7ccbc5c..4eee41a 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -184,11 +184,6 @@ export default function MelodyDetail() { {getLocalizedValue(info.description, displayLang)} -
- - {info.notes?.length > 0 ? info.notes.join(", ") : "-"} - -
{info.customTags?.length > 0 ? (