Phase 3 Complete by Claude Code
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from config import settings
|
|||||||
from shared.firebase import init_firebase, firebase_initialized
|
from shared.firebase import init_firebase, firebase_initialized
|
||||||
from auth.router import router as auth_router
|
from auth.router import router as auth_router
|
||||||
from melodies.router import router as melodies_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
|
from settings.router import router as settings_router
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@@ -23,6 +24,7 @@ app.add_middleware(
|
|||||||
|
|
||||||
app.include_router(auth_router)
|
app.include_router(auth_router)
|
||||||
app.include_router(melodies_router)
|
app.include_router(melodies_router)
|
||||||
|
app.include_router(devices_router)
|
||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
BIN
bellsystemsblack-300x138_lightTheme.png
Normal file
BIN
bellsystemsblack-300x138_lightTheme.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -6,6 +6,9 @@ import MelodyList from "./melodies/MelodyList";
|
|||||||
import MelodyDetail from "./melodies/MelodyDetail";
|
import MelodyDetail from "./melodies/MelodyDetail";
|
||||||
import MelodyForm from "./melodies/MelodyForm";
|
import MelodyForm from "./melodies/MelodyForm";
|
||||||
import MelodySettings from "./melodies/MelodySettings";
|
import MelodySettings from "./melodies/MelodySettings";
|
||||||
|
import DeviceList from "./devices/DeviceList";
|
||||||
|
import DeviceDetail from "./devices/DeviceDetail";
|
||||||
|
import DeviceForm from "./devices/DeviceForm";
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
function ProtectedRoute({ children }) {
|
||||||
const { user, loading } = useAuth();
|
const { user, loading } = useAuth();
|
||||||
@@ -55,8 +58,11 @@ export default function App() {
|
|||||||
<Route path="melodies/new" element={<MelodyForm />} />
|
<Route path="melodies/new" element={<MelodyForm />} />
|
||||||
<Route path="melodies/:id" element={<MelodyDetail />} />
|
<Route path="melodies/:id" element={<MelodyDetail />} />
|
||||||
<Route path="melodies/:id/edit" element={<MelodyForm />} />
|
<Route path="melodies/:id/edit" element={<MelodyForm />} />
|
||||||
{/* Phase 3+ routes:
|
|
||||||
<Route path="devices" element={<DeviceList />} />
|
<Route path="devices" element={<DeviceList />} />
|
||||||
|
<Route path="devices/new" element={<DeviceForm />} />
|
||||||
|
<Route path="devices/:id" element={<DeviceDetail />} />
|
||||||
|
<Route path="devices/:id/edit" element={<DeviceForm />} />
|
||||||
|
{/* Phase 4+ routes:
|
||||||
<Route path="users" element={<UserList />} />
|
<Route path="users" element={<UserList />} />
|
||||||
<Route path="mqtt" element={<MqttDashboard />} />
|
<Route path="mqtt" element={<MqttDashboard />} />
|
||||||
*/}
|
*/}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-1 text-sm text-gray-900">{children || "-"}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BoolBadge({ value, yesLabel = "Yes", noLabel = "No" }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`px-2 py-0.5 text-xs rounded-full ${
|
||||||
|
value ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{value ? yesLabel : noLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <div className="text-center py-8 text-gray-500">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/devices")}
|
||||||
|
className="text-sm text-blue-600 hover:text-blue-800 mb-2 inline-block"
|
||||||
|
>
|
||||||
|
← Back to Devices
|
||||||
|
</button>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">
|
||||||
|
{device.device_name || "Unnamed Device"}
|
||||||
|
</h1>
|
||||||
|
<span
|
||||||
|
className={`inline-block w-3 h-3 rounded-full ${
|
||||||
|
device.is_Online ? "bg-green-500" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
title={device.is_Online ? "Online" : "Offline"}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/devices/${id}/edit`)}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDelete(true)}
|
||||||
|
className="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700 transition-colors"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
{/* Left column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Basic Info */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Serial Number">
|
||||||
|
<span className="font-mono">{device.device_id}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Document ID">
|
||||||
|
<span className="font-mono text-xs text-gray-500">{device.id}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Status">
|
||||||
|
<BoolBadge value={device.is_Online} yesLabel="Online" noLabel="Offline" />
|
||||||
|
</Field>
|
||||||
|
<div className="col-span-2 md:col-span-3">
|
||||||
|
<Field label="Location">{device.device_location}</Field>
|
||||||
|
</div>
|
||||||
|
<Field label="Coordinates">{device.device_location_coordinates}</Field>
|
||||||
|
<Field label="Events On">
|
||||||
|
<BoolBadge value={device.events_on} />
|
||||||
|
</Field>
|
||||||
|
<Field label="WebSocket URL">{device.websocket_url}</Field>
|
||||||
|
<div className="col-span-2 md:col-span-3">
|
||||||
|
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Device Attributes */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Device Attributes
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
|
||||||
|
<Field label="Has Clock"><BoolBadge value={attr.hasClock} /></Field>
|
||||||
|
<Field label="Has Bells"><BoolBadge value={attr.hasBells} /></Field>
|
||||||
|
<Field label="Total Bells">{attr.totalBells}</Field>
|
||||||
|
<Field label="Bell Guard"><BoolBadge value={attr.bellGuardOn} /></Field>
|
||||||
|
<Field label="Bell Guard Safety"><BoolBadge value={attr.bellGuardSafetyOn} /></Field>
|
||||||
|
<Field label="Warnings On"><BoolBadge value={attr.warningsOn} /></Field>
|
||||||
|
<Field label="Device Locale">
|
||||||
|
<span className="capitalize">{attr.deviceLocale}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Serial Log Level">{attr.serialLogLevel}</Field>
|
||||||
|
<Field label="SD Log Level">{attr.sdLogLevel}</Field>
|
||||||
|
<Field label="Bell Outputs">
|
||||||
|
{attr.bellOutputs?.length > 0 ? attr.bellOutputs.join(", ") : "-"}
|
||||||
|
</Field>
|
||||||
|
<Field label="Hammer Timings">
|
||||||
|
{attr.hammerTimings?.length > 0 ? attr.hammerTimings.join(", ") : "-"}
|
||||||
|
</Field>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Network */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Network Settings
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Hostname">{net.hostname}</Field>
|
||||||
|
<Field label="Static IP"><BoolBadge value={net.useStaticIP} /></Field>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right column */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Subscription */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Subscription
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Tier">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 capitalize">
|
||||||
|
{sub.subscrTier}
|
||||||
|
</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Start Date">{sub.subscrStart}</Field>
|
||||||
|
<Field label="Duration">{sub.subscrDuration} months</Field>
|
||||||
|
<Field label="Max Users">{sub.maxUsers}</Field>
|
||||||
|
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Clock Settings */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Clock Settings
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Ring Alerts Master"><BoolBadge value={clock.ringAlertsMasterOn} /></Field>
|
||||||
|
<Field label="Ring Alerts">
|
||||||
|
<span className="capitalize">{clock.ringAlerts}</span>
|
||||||
|
</Field>
|
||||||
|
<Field label="Ring Intervals">{clock.ringIntervals}</Field>
|
||||||
|
<Field label="Hour Alerts Bell">{clock.hourAlertsBell}</Field>
|
||||||
|
<Field label="Half-hour Bell">{clock.halfhourAlertsBell}</Field>
|
||||||
|
<Field label="Quarter Bell">{clock.quarterAlertsBell}</Field>
|
||||||
|
<Field label="Backlight Output">{clock.backlightOutput}</Field>
|
||||||
|
<Field label="Backlight Auto"><BoolBadge value={clock.isBacklightAutomationOn} /></Field>
|
||||||
|
<Field label="Clock Outputs">
|
||||||
|
{clock.clockOutputs?.length > 0 ? clock.clockOutputs.join(", ") : "-"}
|
||||||
|
</Field>
|
||||||
|
<Field label="Clock Timings">
|
||||||
|
{clock.clockTimings?.length > 0 ? clock.clockTimings.join(", ") : "-"}
|
||||||
|
</Field>
|
||||||
|
</dl>
|
||||||
|
{(clock.isDaySilenceOn || clock.isNightSilenceOn) && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-100">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Silence Periods</h3>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
{clock.isDaySilenceOn && (
|
||||||
|
<>
|
||||||
|
<Field label="Day Silence">
|
||||||
|
{clock.daySilenceFrom} - {clock.daySilenceTo}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{clock.isNightSilenceOn && (
|
||||||
|
<>
|
||||||
|
<Field label="Night Silence">
|
||||||
|
{clock.nightSilenceFrom} - {clock.nightSilenceTo}
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Statistics */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Statistics & Warranty
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
|
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
||||||
|
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
||||||
|
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
||||||
|
<Field label="Per-Bell Strikes">
|
||||||
|
{stats.perBellStrikes?.length > 0 ? stats.perBellStrikes.join(", ") : "-"}
|
||||||
|
</Field>
|
||||||
|
<Field label="Warranty Active"><BoolBadge value={stats.warrantyActive} /></Field>
|
||||||
|
<Field label="Warranty Start">{stats.warrantyStart}</Field>
|
||||||
|
<Field label="Warranty Period">{stats.warrantyPeriod} months</Field>
|
||||||
|
<Field label="Last Maintained">{stats.maintainedOn}</Field>
|
||||||
|
<Field label="Maintenance Period">{stats.maintainancePeriod} months</Field>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Melodies & Users summary */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Melodies & Users
|
||||||
|
</h2>
|
||||||
|
<dl className="grid grid-cols-2 gap-4">
|
||||||
|
<Field label="Total Melodies">
|
||||||
|
{device.device_melodies_all?.length ?? 0}
|
||||||
|
</Field>
|
||||||
|
<Field label="Favorite Melodies">
|
||||||
|
{device.device_melodies_favorites?.length ?? 0}
|
||||||
|
</Field>
|
||||||
|
<Field label="Assigned Users">
|
||||||
|
{device.user_list?.length ?? 0}
|
||||||
|
</Field>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={showDelete}
|
||||||
|
title="Delete Device"
|
||||||
|
message={`Are you sure you want to delete "${device.device_name || "this device"}" (${device.device_id})? This action cannot be undone.`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setShowDelete(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 <div className="text-center py-8 text-gray-500">Loading...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900 mb-6">
|
||||||
|
{isEdit ? "Edit Device" : "Add Device"}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||||
|
{/* ===== Left Column ===== */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* --- Basic Info --- */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Basic Information
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Device Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={deviceName}
|
||||||
|
onChange={(e) => setDeviceName(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Location
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={deviceLocation}
|
||||||
|
onChange={(e) => setDeviceLocation(e.target.value)}
|
||||||
|
placeholder="e.g. St. Mary's Church, Vienna"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Location Coordinates
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={locationCoordinates}
|
||||||
|
onChange={(e) => setLocationCoordinates(e.target.value)}
|
||||||
|
placeholder="e.g. 48.2082,16.3738"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Device Photo URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={devicePhoto}
|
||||||
|
onChange={(e) => setDevicePhoto(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-4 pt-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eventsOn}
|
||||||
|
onChange={(e) => setEventsOn(e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Events On</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Device Attributes --- */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Device Attributes
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="flex flex-wrap gap-4 md:col-span-2">
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.hasAssistant}
|
||||||
|
onChange={(e) => updateAttr("hasAssistant", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Has Assistant</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.hasClock}
|
||||||
|
onChange={(e) => updateAttr("hasClock", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Has Clock</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.hasBells}
|
||||||
|
onChange={(e) => updateAttr("hasBells", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Has Bells</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.bellGuardOn}
|
||||||
|
onChange={(e) => updateAttr("bellGuardOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Bell Guard</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.bellGuardSafetyOn}
|
||||||
|
onChange={(e) => updateAttr("bellGuardSafetyOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Bell Guard Safety</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.warningsOn}
|
||||||
|
onChange={(e) => updateAttr("warningsOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Warnings On</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Total Bells
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.totalBells}
|
||||||
|
onChange={(e) => updateAttr("totalBells", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Device Locale
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={attributes.deviceLocale}
|
||||||
|
onChange={(e) => updateAttr("deviceLocale", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{LOCALE_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o.charAt(0).toUpperCase() + o.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Bell Outputs (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attributes.bellOutputs.join(", ")}
|
||||||
|
onChange={(e) => updateAttr("bellOutputs", parseIntList(e.target.value))}
|
||||||
|
placeholder="e.g. 1, 2, 3"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Hammer Timings (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attributes.hammerTimings.join(", ")}
|
||||||
|
onChange={(e) => updateAttr("hammerTimings", parseIntList(e.target.value))}
|
||||||
|
placeholder="e.g. 100, 150, 200"
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Serial Log Level
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.serialLogLevel}
|
||||||
|
onChange={(e) => updateAttr("serialLogLevel", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
SD Log Level
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.sdLogLevel}
|
||||||
|
onChange={(e) => updateAttr("sdLogLevel", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Network Settings --- */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Network Settings
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Hostname
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attributes.networkSettings.hostname}
|
||||||
|
onChange={(e) => updateNetwork("hostname", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-6">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="useStaticIP"
|
||||||
|
checked={attributes.networkSettings.useStaticIP}
|
||||||
|
onChange={(e) => updateNetwork("useStaticIP", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<label htmlFor="useStaticIP" className="text-sm font-medium text-gray-700">
|
||||||
|
Use Static IP
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
WebSocket URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={websocketUrl}
|
||||||
|
onChange={(e) => setWebsocketUrl(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Church Assistant URL
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={churchAssistantURL}
|
||||||
|
onChange={(e) => setChurchAssistantURL(e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ===== Right Column ===== */}
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* --- Subscription --- */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Subscription
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Tier
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={subscription.subscrTier}
|
||||||
|
onChange={(e) => updateSub("subscrTier", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{TIER_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Start Date
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={subscription.subscrStart}
|
||||||
|
onChange={(e) => updateSub("subscrStart", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Duration (months)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={subscription.subscrDuration}
|
||||||
|
onChange={(e) => updateSub("subscrDuration", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Users
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={subscription.maxUsers}
|
||||||
|
onChange={(e) => updateSub("maxUsers", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Max Outputs
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={subscription.maxOutputs}
|
||||||
|
onChange={(e) => updateSub("maxOutputs", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Clock Settings --- */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Clock Settings
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ring Alerts
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={attributes.clockSettings.ringAlerts}
|
||||||
|
onChange={(e) => updateClock("ringAlerts", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
{RING_ALERT_OPTIONS.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o.charAt(0).toUpperCase() + o.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Ring Intervals
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.clockSettings.ringIntervals}
|
||||||
|
onChange={(e) => updateClock("ringIntervals", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.clockSettings.ringAlertsMasterOn}
|
||||||
|
onChange={(e) => updateClock("ringAlertsMasterOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Ring Alerts Master On</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Clock Outputs (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attributes.clockSettings.clockOutputs.join(", ")}
|
||||||
|
onChange={(e) => updateClock("clockOutputs", parseIntList(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Clock Timings (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={attributes.clockSettings.clockTimings.join(", ")}
|
||||||
|
onChange={(e) => updateClock("clockTimings", parseIntList(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Hour Alerts Bell
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.clockSettings.hourAlertsBell}
|
||||||
|
onChange={(e) => updateClock("hourAlertsBell", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Half-hour Alerts Bell
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.clockSettings.halfhourAlertsBell}
|
||||||
|
onChange={(e) => updateClock("halfhourAlertsBell", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Quarter Alerts Bell
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.clockSettings.quarterAlertsBell}
|
||||||
|
onChange={(e) => updateClock("quarterAlertsBell", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Backlight Output
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={attributes.clockSettings.backlightOutput}
|
||||||
|
onChange={(e) => updateClock("backlightOutput", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.clockSettings.isBacklightAutomationOn}
|
||||||
|
onChange={(e) => updateClock("isBacklightAutomationOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Backlight Automation</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Silence settings */}
|
||||||
|
<div className="md:col-span-2 border-t border-gray-100 pt-4 mt-2">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700 mb-3">Silence Periods</h3>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.clockSettings.isDaySilenceOn}
|
||||||
|
onChange={(e) => updateClock("isDaySilenceOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Day Silence</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={attributes.clockSettings.isNightSilenceOn}
|
||||||
|
onChange={(e) => updateClock("isNightSilenceOn", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-700">Night Silence</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Day Silence From</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={attributes.clockSettings.daySilenceFrom}
|
||||||
|
onChange={(e) => updateClock("daySilenceFrom", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Day Silence To</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={attributes.clockSettings.daySilenceTo}
|
||||||
|
onChange={(e) => updateClock("daySilenceTo", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Night Silence From</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={attributes.clockSettings.nightSilenceFrom}
|
||||||
|
onChange={(e) => updateClock("nightSilenceFrom", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-gray-500 mb-1">Night Silence To</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={attributes.clockSettings.nightSilenceTo}
|
||||||
|
onChange={(e) => updateClock("nightSilenceTo", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* --- Statistics --- */}
|
||||||
|
<section className="bg-white rounded-lg border border-gray-200 p-6">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-800 mb-4">
|
||||||
|
Statistics & Warranty
|
||||||
|
</h2>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Total Playbacks
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={stats.totalPlaybacks}
|
||||||
|
onChange={(e) => updateStats("totalPlaybacks", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Total Hammer Strikes
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={stats.totalHammerStrikes}
|
||||||
|
onChange={(e) => updateStats("totalHammerStrikes", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Total Warnings Given
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={stats.totalWarningsGiven}
|
||||||
|
onChange={(e) => updateStats("totalWarningsGiven", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Per-Bell Strikes (comma-separated)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={stats.perBellStrikes.join(", ")}
|
||||||
|
onChange={(e) => updateStats("perBellStrikes", parseIntList(e.target.value))}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 pt-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={stats.warrantyActive}
|
||||||
|
onChange={(e) => updateStats("warrantyActive", e.target.checked)}
|
||||||
|
className="h-4 w-4 text-blue-600 rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium text-gray-700">Warranty Active</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Warranty Start
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={stats.warrantyStart}
|
||||||
|
onChange={(e) => updateStats("warrantyStart", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Warranty Period (months)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={stats.warrantyPeriod}
|
||||||
|
onChange={(e) => updateStats("warrantyPeriod", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Last Maintained On
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={stats.maintainedOn}
|
||||||
|
onChange={(e) => updateStats("maintainedOn", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Maintenance Period (months)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={stats.maintainancePeriod}
|
||||||
|
onChange={(e) => updateStats("maintainancePeriod", parseInt(e.target.value, 10) || 0)}
|
||||||
|
className={inputClass}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* --- Actions --- */}
|
||||||
|
<div className="flex gap-3 mt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="px-6 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{saving ? "Saving..." : isEdit ? "Update Device" : "Create Device"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(isEdit ? `/devices/${id}` : "/devices")}
|
||||||
|
className="px-6 py-2 bg-gray-100 text-gray-700 text-sm rounded-md hover:bg-gray-200 transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Devices</h1>
|
||||||
|
{canEdit && (
|
||||||
|
<button
|
||||||
|
onClick={() => navigate("/devices/new")}
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
Add Device
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4 space-y-3">
|
||||||
|
<SearchBar
|
||||||
|
onSearch={setSearch}
|
||||||
|
placeholder="Search by name, location, or serial number..."
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap gap-3 items-center">
|
||||||
|
<select
|
||||||
|
value={onlineFilter}
|
||||||
|
onChange={(e) => setOnlineFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="true">Online</option>
|
||||||
|
<option value="false">Offline</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={tierFilter}
|
||||||
|
onChange={(e) => setTierFilter(e.target.value)}
|
||||||
|
className="px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
|
||||||
|
>
|
||||||
|
<option value="">All Tiers</option>
|
||||||
|
{TIER_OPTIONS.filter(Boolean).map((t) => (
|
||||||
|
<option key={t} value={t}>
|
||||||
|
{t.charAt(0).toUpperCase() + t.slice(1)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="flex items-center text-sm text-gray-500">
|
||||||
|
{total} {total === 1 ? "device" : "devices"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-50 border border-red-200 text-red-700 text-sm rounded-md p-3 mb-4">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="text-center py-8 text-gray-500">Loading...</div>
|
||||||
|
) : devices.length === 0 ? (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 p-8 text-center text-gray-500 text-sm">
|
||||||
|
No devices found.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="bg-white rounded-lg border border-gray-200 overflow-hidden">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-gray-50 border-b border-gray-200">
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600 w-10">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Name</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Serial Number</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Location</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Tier</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Bells</th>
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600">Users</th>
|
||||||
|
{canEdit && (
|
||||||
|
<th className="px-4 py-3 text-left font-medium text-gray-600 w-24" />
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{devices.map((device) => (
|
||||||
|
<tr
|
||||||
|
key={device.id}
|
||||||
|
onClick={() => navigate(`/devices/${device.id}`)}
|
||||||
|
className="border-b border-gray-100 last:border-0 cursor-pointer hover:bg-gray-50"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block w-2.5 h-2.5 rounded-full ${
|
||||||
|
device.is_Online ? "bg-green-500" : "bg-gray-300"
|
||||||
|
}`}
|
||||||
|
title={device.is_Online ? "Online" : "Offline"}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-medium text-gray-900">
|
||||||
|
{device.device_name || "Unnamed Device"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 font-mono text-xs text-gray-500">
|
||||||
|
{device.device_id || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">
|
||||||
|
{device.device_location || "-"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className="px-2 py-0.5 text-xs rounded-full bg-blue-50 text-blue-700 capitalize">
|
||||||
|
{device.device_subscription?.subscrTier || "basic"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">
|
||||||
|
{device.device_attributes?.totalBells ?? 0}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">
|
||||||
|
{device.user_list?.length ?? 0}
|
||||||
|
</td>
|
||||||
|
{canEdit && (
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div
|
||||||
|
className="flex gap-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(`/devices/${device.id}/edit`)}
|
||||||
|
className="text-blue-600 hover:text-blue-800 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteTarget(device)}
|
||||||
|
className="text-red-600 hover:text-red-800 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={!!deleteTarget}
|
||||||
|
title="Delete Device"
|
||||||
|
message={`Are you sure you want to delete "${deleteTarget?.device_name || "this device"}" (${deleteTarget?.device_id || ""})? This action cannot be undone.`}
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,11 +184,6 @@ export default function MelodyDetail() {
|
|||||||
{getLocalizedValue(info.description, displayLang)}
|
{getLocalizedValue(info.description, displayLang)}
|
||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-2 md:col-span-3">
|
|
||||||
<Field label="Notes">
|
|
||||||
{info.notes?.length > 0 ? info.notes.join(", ") : "-"}
|
|
||||||
</Field>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<Field label="Custom Tags">
|
<Field label="Custom Tags">
|
||||||
{info.customTags?.length > 0 ? (
|
{info.customTags?.length > 0 ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user