Phase 3 Complete by Claude Code

This commit is contained in:
2026-02-17 14:05:39 +02:00
parent 115c3773ef
commit 337712ffac
11 changed files with 1818 additions and 13 deletions

View File

@@ -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"
>
&larr; 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}