Added Roles and Permissions. Some minor UI fixes
This commit is contained in:
@@ -165,8 +165,8 @@ function formatCoordinates(coords) {
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { hasRole } = useAuth();
|
||||
const canEdit = hasRole("superadmin", "device_manager");
|
||||
const { hasPermission } = useAuth();
|
||||
const canEdit = hasPermission("devices", "edit");
|
||||
|
||||
const [device, setDevice] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -298,35 +298,71 @@ export default function DeviceDetail() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
|
||||
{/* Left column */}
|
||||
<div className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<SectionCard title="Basic Information">
|
||||
<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="Status">
|
||||
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
|
||||
{mqttStatus && (
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Events On">
|
||||
<BoolBadge value={device.events_on} />
|
||||
</Field>
|
||||
<Field label="Document ID">
|
||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
|
||||
</Field>
|
||||
</dl>
|
||||
</SectionCard>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-6">
|
||||
{/* Basic Information */}
|
||||
<SectionCard title="Basic Information">
|
||||
<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="Status">
|
||||
<BoolBadge value={isOnline} yesLabel="Online" noLabel="Offline" />
|
||||
{mqttStatus && (
|
||||
<span className="ml-2 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
(MQTT {mqttStatus.seconds_since_heartbeat}s ago)
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Document ID">
|
||||
<span className="font-mono text-xs" style={{ color: "var(--text-muted)" }}>{device.id}</span>
|
||||
</Field>
|
||||
</dl>
|
||||
</SectionCard>
|
||||
|
||||
{/* Location */}
|
||||
<SectionCard title="Location">
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
|
||||
{/* Misc */}
|
||||
<SectionCard title="Misc">
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Automated Events"><BoolBadge value={device.events_on} yesLabel="ON" noLabel="OFF" /></Field>
|
||||
<Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
|
||||
<Field label="WebSocket URL">{device.websocket_url}</Field>
|
||||
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
|
||||
<div className="col-span-2 md:col-span-3">
|
||||
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
|
||||
</div>
|
||||
</dl>
|
||||
</SectionCard>
|
||||
|
||||
{/* Subscription */}
|
||||
<SectionCard title="Subscription">
|
||||
<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 capitalize" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||
{sub.subscrTier}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
|
||||
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
|
||||
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
|
||||
<Field label="Time Left">
|
||||
{subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||
Subscription Expired
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||
{subscrDaysLeft} days left
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Max Users">{sub.maxUsers}</Field>
|
||||
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
||||
</dl>
|
||||
</SectionCard>
|
||||
|
||||
{/* Location */}
|
||||
<SectionCard title="Location">
|
||||
<div className={coords ? "grid grid-cols-1 md:grid-cols-2 gap-4" : ""}>
|
||||
<dl className="space-y-4">
|
||||
<Field label="Location">{device.device_location}</Field>
|
||||
<Field label="Coordinates">
|
||||
<div>
|
||||
@@ -350,8 +386,8 @@ export default function DeviceDetail() {
|
||||
)}
|
||||
</dl>
|
||||
{coords && (
|
||||
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", height: 200 }}>
|
||||
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%" }} scrollWheelZoom={false}>
|
||||
<div className="rounded-md overflow-hidden border" style={{ borderColor: "var(--border-primary)", minHeight: 300 }}>
|
||||
<MapContainer center={[coords.lat, coords.lng]} zoom={13} style={{ height: "100%", width: "100%", minHeight: 300 }} scrollWheelZoom={false}>
|
||||
<TileLayer
|
||||
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
@@ -360,36 +396,118 @@ export default function DeviceDetail() {
|
||||
</MapContainer>
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
</div>
|
||||
</SectionCard>
|
||||
|
||||
{/* Subscription */}
|
||||
<SectionCard title="Subscription">
|
||||
<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 capitalize" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||
{sub.subscrTier}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
|
||||
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
|
||||
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
|
||||
<Field label="Time Left">
|
||||
{subscrDaysLeft === null ? "-" : subscrDaysLeft <= 0 ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
|
||||
Subscription Expired
|
||||
</span>
|
||||
{/* Warranty, Maintenance & Statistics */}
|
||||
<SectionCard title="Warranty, Maintenance & Statistics">
|
||||
{/* Subsection 1: Warranty */}
|
||||
<Subsection title="Warranty Information" isFirst>
|
||||
<Field label="Warranty Status">
|
||||
{warrantyDaysLeft !== null ? (
|
||||
warrantyDaysLeft > 0 ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||
{subscrDaysLeft} days left
|
||||
</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Max Users">{sub.maxUsers}</Field>
|
||||
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
||||
</dl>
|
||||
</SectionCard>
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
|
||||
)
|
||||
) : (
|
||||
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
||||
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
||||
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
||||
<Field label="Remaining">
|
||||
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
|
||||
<span style={{ color: "var(--danger-text)" }}>Expired</span>
|
||||
) : (
|
||||
`${warrantyDaysLeft} days`
|
||||
)}
|
||||
</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Device Settings (combined) */}
|
||||
{/* Subsection 2: Maintenance */}
|
||||
<Subsection title="Maintenance">
|
||||
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
||||
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
||||
<Field label="Next Scheduled">
|
||||
{nextMaintenance ? (
|
||||
<span>
|
||||
{formatDateNice(nextMaintenance)}
|
||||
{maintenanceDaysLeft !== null && (
|
||||
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
|
||||
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : "-"}
|
||||
</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3: Statistics */}
|
||||
<Subsection title="Statistics">
|
||||
<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="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
||||
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
||||
{stats.perBellStrikes?.length > 0 && (
|
||||
<div className="col-span-2 md:col-span-3">
|
||||
<Field label="Per Bell Strikes">
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
||||
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||
Bell {i + 1}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Subsection>
|
||||
</SectionCard>
|
||||
|
||||
{/* Users */}
|
||||
<SectionCard title={`App Users (${deviceUsers.length})`}>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
|
||||
) : deviceUsers.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deviceUsers.map((user, i) => (
|
||||
<div
|
||||
key={user.user_id || i}
|
||||
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
||||
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
||||
{user.display_name || user.email || "Unknown User"}
|
||||
</p>
|
||||
{user.email && user.display_name && (
|
||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
|
||||
)}
|
||||
{user.user_id && (
|
||||
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
|
||||
)}
|
||||
</div>
|
||||
{user.role && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Device Settings — spans 2 columns on ultrawide */}
|
||||
<div className="2xl:col-span-2">
|
||||
<SectionCard title="Device Settings">
|
||||
{/* Subsection 1: Basic Attributes */}
|
||||
<Subsection title="Basic Attributes" isFirst>
|
||||
@@ -456,130 +574,8 @@ export default function DeviceDetail() {
|
||||
</SectionCard>
|
||||
</div>
|
||||
|
||||
{/* Right column */}
|
||||
<div className="space-y-6">
|
||||
{/* Misc */}
|
||||
<SectionCard title="Misc">
|
||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||
<Field label="Device Locale"><span className="capitalize">{attr.deviceLocale || "-"}</span></Field>
|
||||
<Field label="WebSocket URL">{device.websocket_url}</Field>
|
||||
<Field label="Has Assistant"><BoolBadge value={attr.hasAssistant} /></Field>
|
||||
<div className="col-span-2 md:col-span-3">
|
||||
<Field label="Church Assistant URL">{device.churchAssistantURL}</Field>
|
||||
</div>
|
||||
</dl>
|
||||
</SectionCard>
|
||||
|
||||
{/* Warranty & Maintenance & Statistics */}
|
||||
<SectionCard title="Warranty, Maintenance & Statistics">
|
||||
{/* Subsection 1: Warranty */}
|
||||
<Subsection title="Warranty Information" isFirst>
|
||||
<Field label="Warranty Status">
|
||||
{warrantyDaysLeft !== null ? (
|
||||
warrantyDaysLeft > 0 ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>Active</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>Expired</span>
|
||||
)
|
||||
) : (
|
||||
<BoolBadge value={stats.warrantyActive} yesLabel="Active" noLabel="Expired" />
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
||||
<Field label="Warranty Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
||||
<Field label="Expiration Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
||||
<Field label="Remaining">
|
||||
{warrantyDaysLeft === null ? "-" : warrantyDaysLeft <= 0 ? (
|
||||
<span style={{ color: "var(--danger-text)" }}>Expired</span>
|
||||
) : (
|
||||
`${warrantyDaysLeft} days`
|
||||
)}
|
||||
</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 2: Maintenance */}
|
||||
<Subsection title="Maintenance">
|
||||
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
||||
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
||||
<Field label="Next Scheduled">
|
||||
{nextMaintenance ? (
|
||||
<span>
|
||||
{formatDateNice(nextMaintenance)}
|
||||
{maintenanceDaysLeft !== null && (
|
||||
<span className="ml-1 text-xs" style={{ color: maintenanceDaysLeft <= 0 ? "var(--danger-text)" : "var(--text-muted)" }}>
|
||||
({maintenanceDaysLeft <= 0 ? "overdue" : formatRelativeTime(maintenanceDaysLeft)})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
) : "-"}
|
||||
</Field>
|
||||
</Subsection>
|
||||
|
||||
{/* Subsection 3: Statistics */}
|
||||
<Subsection title="Statistics">
|
||||
<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="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
||||
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
||||
{stats.perBellStrikes?.length > 0 && (
|
||||
<div className="col-span-2 md:col-span-3">
|
||||
<Field label="Per Bell Strikes">
|
||||
<div className="flex flex-wrap gap-2 mt-1">
|
||||
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
||||
<span key={i} className="px-2 py-1 text-xs rounded-md border" style={{ borderColor: "var(--border-primary)", color: "var(--text-primary)" }}>
|
||||
Bell {i + 1}: {count}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</Subsection>
|
||||
</SectionCard>
|
||||
|
||||
{/* Users */}
|
||||
<SectionCard title={`Users (${deviceUsers.length})`}>
|
||||
{usersLoading ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Loading users...</p>
|
||||
) : deviceUsers.length === 0 ? (
|
||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No users assigned to this device.</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{deviceUsers.map((user, i) => (
|
||||
<div
|
||||
key={user.user_id || i}
|
||||
className="p-3 rounded-md border cursor-pointer hover:opacity-90 transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)" }}
|
||||
onClick={() => user.user_id && navigate(`/users/${user.user_id}`)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate" style={{ color: "var(--text-heading)" }}>
|
||||
{user.display_name || user.email || "Unknown User"}
|
||||
</p>
|
||||
{user.email && user.display_name && (
|
||||
<p className="text-xs truncate" style={{ color: "var(--text-muted)" }}>{user.email}</p>
|
||||
)}
|
||||
{user.user_id && (
|
||||
<p className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{user.user_id}</p>
|
||||
)}
|
||||
</div>
|
||||
{user.role && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full capitalize shrink-0 ml-2" style={{ backgroundColor: "var(--badge-blue-bg)", color: "var(--badge-blue-text)" }}>
|
||||
{user.role}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</SectionCard>
|
||||
|
||||
{/* Equipment Notes */}
|
||||
<NotesPanel deviceId={id} />
|
||||
</div>
|
||||
{/* Equipment Notes */}
|
||||
<NotesPanel deviceId={id} />
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
Reference in New Issue
Block a user