Added Roles and Permissions. Some minor UI fixes

This commit is contained in:
2026-02-18 13:12:55 +02:00
parent f54cdd525d
commit dbd15c00f8
31 changed files with 1825 additions and 331 deletions

View File

@@ -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='&copy; <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

View File

@@ -108,10 +108,11 @@ export default function DeviceList() {
const [deleteTarget, setDeleteTarget] = useState(null);
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [mqttStatusMap, setMqttStatusMap] = useState({});
const columnPickerRef = useRef(null);
const navigate = useNavigate();
const { hasRole } = useAuth();
const canEdit = hasRole("superadmin", "device_manager");
const { hasPermission } = useAuth();
const canEdit = hasPermission("devices", "edit");
// Close column picker on outside click
useEffect(() => {
@@ -136,8 +137,20 @@ export default function DeviceList() {
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}` : ""}`);
const [data, mqttData] = await Promise.all([
api.get(`/devices${qs ? `?${qs}` : ""}`),
api.get("/mqtt/status").catch(() => null),
]);
setDevices(data.devices);
// Build MQTT status lookup by device serial
if (mqttData?.devices) {
const map = {};
for (const s of mqttData.devices) {
map[s.device_serial] = s;
}
setMqttStatusMap(map);
}
} catch (err) {
setError(err.message);
} finally {
@@ -182,14 +195,17 @@ export default function DeviceList() {
const stats = device.device_stats || {};
switch (key) {
case "status":
case "status": {
const mqtt = mqttStatusMap[device.device_id];
const isOnline = mqtt ? mqtt.online : device.is_Online;
return (
<span
className={`inline-block w-2.5 h-2.5 rounded-full ${device.is_Online ? "bg-green-500" : ""}`}
style={!device.is_Online ? { backgroundColor: "var(--border-primary)" } : undefined}
title={device.is_Online ? "Online" : "Offline"}
className={`inline-block w-2.5 h-2.5 rounded-full ${isOnline ? "bg-green-500" : ""}`}
style={!isOnline ? { backgroundColor: "var(--border-primary)" } : undefined}
title={isOnline ? "Online" : "Offline"}
/>
);
}
case "name":
return (
<span className="font-medium" style={{ color: "var(--text-heading)" }}>