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 0000000..378e9f6 Binary files /dev/null and b/bellsystemsblack-300x138_lightTheme.png differ 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 ? (