added device images and fixed layout issues
This commit is contained in:
BIN
VesperPlus.png
Normal file
BIN
VesperPlus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -125,6 +125,7 @@ class DeviceCreate(BaseModel):
|
|||||||
user_list: List[str] = []
|
user_list: List[str] = []
|
||||||
websocket_url: str = ""
|
websocket_url: str = ""
|
||||||
churchAssistantURL: str = ""
|
churchAssistantURL: str = ""
|
||||||
|
staffNotes: str = ""
|
||||||
|
|
||||||
|
|
||||||
class DeviceUpdate(BaseModel):
|
class DeviceUpdate(BaseModel):
|
||||||
@@ -142,6 +143,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
user_list: Optional[List[str]] = None
|
user_list: Optional[List[str]] = None
|
||||||
websocket_url: Optional[str] = None
|
websocket_url: Optional[str] = None
|
||||||
churchAssistantURL: Optional[str] = None
|
churchAssistantURL: Optional[str] = None
|
||||||
|
staffNotes: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceInDB(DeviceCreate):
|
class DeviceInDB(DeviceCreate):
|
||||||
|
|||||||
BIN
frontend/public/devices/VesperPlus.png
Normal file
BIN
frontend/public/devices/VesperPlus.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from "react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
import { useParams, useNavigate } from "react-router-dom";
|
import { useParams, useNavigate } from "react-router-dom";
|
||||||
import { MapContainer, TileLayer, Marker } from "react-leaflet";
|
import { MapContainer, TileLayer, Marker } from "react-leaflet";
|
||||||
import "leaflet/dist/leaflet.css";
|
import "leaflet/dist/leaflet.css";
|
||||||
@@ -456,50 +456,65 @@ export default function DeviceDetail() {
|
|||||||
|
|
||||||
// ===== Section definitions =====
|
// ===== Section definitions =====
|
||||||
|
|
||||||
|
const [staffNotes, setStaffNotes] = useState(device.staffNotes || "");
|
||||||
|
const [editingNotes, setEditingNotes] = useState(false);
|
||||||
|
const [savingNotes, setSavingNotes] = useState(false);
|
||||||
|
const notesRef = useRef(null);
|
||||||
|
|
||||||
|
const saveStaffNotes = async (value) => {
|
||||||
|
setSavingNotes(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/devices/${id}`, { staffNotes: value });
|
||||||
|
setStaffNotes(value);
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
setSavingNotes(false);
|
||||||
|
setEditingNotes(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hardware variant to image mapping
|
||||||
|
const hwImageMap = {
|
||||||
|
VesperPlus: "/devices/VesperPlus.png",
|
||||||
|
};
|
||||||
|
const hwVariant = "VesperPlus"; // TODO: read from device data when available
|
||||||
|
const hwImage = hwImageMap[hwVariant] || hwImageMap.VesperPlus;
|
||||||
|
|
||||||
const deviceInfoSection = (
|
const deviceInfoSection = (
|
||||||
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
<section className="rounded-lg border p-6" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
|
||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Device Information</h2>
|
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Device Information</h2>
|
||||||
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem", alignItems: "start" }}>
|
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: "1rem", alignItems: "start" }}>
|
||||||
{/* Col 1: Status — fancy card filling vertical space */}
|
{/* Col 1: Status on top, device image below */}
|
||||||
<div
|
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem", height: "100%" }}>
|
||||||
className="relative rounded-md border overflow-hidden"
|
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
|
||||||
style={{
|
|
||||||
borderColor: isOnline ? "rgba(116, 184, 22, 0.3)" : "var(--border-primary)",
|
|
||||||
backgroundColor: isOnline ? "rgba(116, 184, 22, 0.05)" : "var(--bg-primary)",
|
|
||||||
padding: "1.25rem",
|
|
||||||
height: "100%",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
justifyContent: "center",
|
|
||||||
alignItems: "center",
|
|
||||||
textAlign: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className="absolute inset-0 flex items-center justify-center font-bold pointer-events-none select-none"
|
|
||||||
style={{ fontSize: "4.5rem", color: isOnline ? "var(--success-text)" : "var(--text-muted)", opacity: 0.06 }}
|
|
||||||
>
|
|
||||||
{isOnline ? "ON" : "OFF"}
|
|
||||||
</span>
|
|
||||||
<div className="relative">
|
|
||||||
<div
|
<div
|
||||||
className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-2"
|
className="w-10 h-10 rounded-full flex items-center justify-center shrink-0"
|
||||||
style={{ backgroundColor: isOnline ? "var(--success-bg)" : "var(--bg-card-hover)" }}
|
style={{ backgroundColor: isOnline ? "var(--success-bg)" : "var(--bg-card-hover)" }}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="w-4 h-4 rounded-full inline-block"
|
className="w-3 h-3 rounded-full inline-block"
|
||||||
style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
|
style={{ backgroundColor: isOnline ? "var(--success-text)" : "var(--text-muted)" }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</div>
|
<div>
|
||||||
<div className="text-sm font-semibold mt-0.5" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
|
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Status</div>
|
||||||
{isOnline ? "Online" : "Offline"}
|
<div className="text-sm font-semibold" style={{ color: isOnline ? "var(--success-text)" : "var(--text-muted)" }}>
|
||||||
</div>
|
{isOnline ? "Online" : "Offline"}
|
||||||
{mqttStatus && (
|
{mqttStatus && (
|
||||||
<div className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>
|
<span className="ml-2 text-xs font-normal" style={{ color: "var(--text-muted)" }}>
|
||||||
{mqttStatus.seconds_since_heartbeat}s ago
|
{mqttStatus.seconds_since_heartbeat}s ago
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center" }}>
|
||||||
|
<img
|
||||||
|
src={hwImage}
|
||||||
|
alt={hwVariant}
|
||||||
|
style={{ maxHeight: 80, maxWidth: "100%", objectFit: "contain", opacity: 0.85 }}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -519,10 +534,62 @@ export default function DeviceDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Col 3: Admin Notes */}
|
{/* Col 3: Admin Notes — editable */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Admin Notes</div>
|
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem", marginBottom: "0.25rem" }}>
|
||||||
<div className="text-sm mt-0.5" style={{ color: "var(--text-muted)" }}>-</div>
|
<div className="text-xs font-medium uppercase tracking-wide" style={{ color: "var(--text-muted)" }}>Admin Notes</div>
|
||||||
|
{!editingNotes && (
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingNotes(true)}
|
||||||
|
className="text-xs hover:opacity-80"
|
||||||
|
style={{ color: "var(--text-muted)", background: "none", border: "none", padding: 0, lineHeight: 1 }}
|
||||||
|
title="Edit notes"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editingNotes ? (
|
||||||
|
<div>
|
||||||
|
<textarea
|
||||||
|
ref={notesRef}
|
||||||
|
defaultValue={staffNotes}
|
||||||
|
autoFocus
|
||||||
|
rows={3}
|
||||||
|
className="w-full px-2 py-1.5 rounded-md text-xs border"
|
||||||
|
style={{ backgroundColor: "var(--bg-input)", color: "var(--text-primary)", borderColor: "var(--border-input)", resize: "vertical" }}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Escape") setEditingNotes(false);
|
||||||
|
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) saveStaffNotes(e.target.value);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<button
|
||||||
|
onClick={() => saveStaffNotes(notesRef.current?.value ?? "")}
|
||||||
|
disabled={savingNotes}
|
||||||
|
className="px-2 py-1 text-xs rounded-md"
|
||||||
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
>
|
||||||
|
{savingNotes ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setEditingNotes(false)}
|
||||||
|
className="px-2 py-1 text-xs rounded-md border"
|
||||||
|
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="text-sm mt-0.5 cursor-pointer hover:opacity-80"
|
||||||
|
style={{ color: staffNotes ? "var(--text-primary)" : "var(--text-muted)" }}
|
||||||
|
onClick={() => setEditingNotes(true)}
|
||||||
|
>
|
||||||
|
{staffNotes || "-"}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -530,8 +597,8 @@ export default function DeviceDetail() {
|
|||||||
|
|
||||||
const subscriptionSection = (
|
const subscriptionSection = (
|
||||||
<SectionCard title="Subscription">
|
<SectionCard title="Subscription">
|
||||||
{/* Row 1: Tier | Last Payment On */}
|
<dl style={{ display: "grid", gridTemplateColumns: "repeat(4, 1fr)", gap: "0.75rem 2rem" }}>
|
||||||
<FieldRow columns={2}>
|
{/* Row 1: Tier | Last Payment On | (empty) | (empty) */}
|
||||||
<Field label="Tier">
|
<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)" }}>
|
<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}
|
{sub.subscrTier}
|
||||||
@@ -540,9 +607,9 @@ export default function DeviceDetail() {
|
|||||||
<Field label="Last Payment On">
|
<Field label="Last Payment On">
|
||||||
<span className="text-sm" style={{ color: "var(--text-primary)" }}>3 months ago</span>
|
<span className="text-sm" style={{ color: "var(--text-primary)" }}>3 months ago</span>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
<div />
|
||||||
{/* Row 2: Start Date | Expiration Date | Duration | Time Left */}
|
<div />
|
||||||
<FieldRow columns={4}>
|
{/* Row 2: Start Date | Expiration Date | Duration | Time Left */}
|
||||||
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
|
<Field label="Start Date">{formatDate(sub.subscrStart)}</Field>
|
||||||
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
|
<Field label="Expiration Date">{subscrEnd ? formatDateNice(subscrEnd) : "-"}</Field>
|
||||||
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
|
<Field label="Duration">{sub.subscrDuration ? daysToDisplay(sub.subscrDuration) : "-"}</Field>
|
||||||
@@ -557,9 +624,7 @@ export default function DeviceDetail() {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
{/* Row 3: Max Users | Max Outputs | Extra Info | (empty) */}
|
||||||
{/* Row 3: Max Users | Max Outputs | Extra Info */}
|
|
||||||
<FieldRow columns={3}>
|
|
||||||
<Field label="Max Users">{sub.maxUsers}</Field>
|
<Field label="Max Users">{sub.maxUsers}</Field>
|
||||||
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
<Field label="Max Outputs">{sub.maxOutputs}</Field>
|
||||||
<Field label="Extra Info">
|
<Field label="Extra Info">
|
||||||
@@ -572,7 +637,8 @@ export default function DeviceDetail() {
|
|||||||
View subscription details
|
View subscription details
|
||||||
</a>
|
</a>
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
<div />
|
||||||
|
</dl>
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -752,8 +818,8 @@ export default function DeviceDetail() {
|
|||||||
const warrantySection = (
|
const warrantySection = (
|
||||||
<SectionCard title="Warranty, Maintenance & Statistics">
|
<SectionCard title="Warranty, Maintenance & Statistics">
|
||||||
<Subsection title="Warranty Information" isFirst>
|
<Subsection title="Warranty Information" isFirst>
|
||||||
{/* Row 1: Status | Time Remaining */}
|
<dl style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem 2rem" }}>
|
||||||
<FieldRow columns={2}>
|
{/* Row 1: Status | Time Remaining | (empty) */}
|
||||||
<Field label="Warranty Status">
|
<Field label="Warranty Status">
|
||||||
{warrantyDaysLeft !== null ? (
|
{warrantyDaysLeft !== null ? (
|
||||||
warrantyDaysLeft > 0 ? (
|
warrantyDaysLeft > 0 ? (
|
||||||
@@ -772,16 +838,14 @@ export default function DeviceDetail() {
|
|||||||
`${warrantyDaysLeft} days`
|
`${warrantyDaysLeft} days`
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
</FieldRow>
|
<div />
|
||||||
{/* Row 2: Start Date | Expiry Date | Period */}
|
{/* Row 2: Start Date | Expiry Date | Period */}
|
||||||
<FieldRow columns={3}>
|
|
||||||
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
<Field label="Start Date">{formatDate(stats.warrantyStart)}</Field>
|
||||||
<Field label="Expiry Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
<Field label="Expiry Date">{warrantyEnd ? formatDateNice(warrantyEnd) : "-"}</Field>
|
||||||
<Field label="Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
<Field label="Period">{stats.warrantyPeriod ? daysToDisplay(stats.warrantyPeriod) : "-"}</Field>
|
||||||
</FieldRow>
|
</dl>
|
||||||
</Subsection>
|
</Subsection>
|
||||||
<Subsection title="Maintenance">
|
<Subsection title="Maintenance">
|
||||||
{/* Single row: all 3 fields */}
|
|
||||||
<FieldRow columns={3}>
|
<FieldRow columns={3}>
|
||||||
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
<Field label="Last Maintained On">{formatDate(stats.maintainedOn)}</Field>
|
||||||
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
<Field label="Maintenance Period">{stats.maintainancePeriod ? daysToDisplay(stats.maintainancePeriod) : "-"}</Field>
|
||||||
@@ -800,20 +864,18 @@ export default function DeviceDetail() {
|
|||||||
</FieldRow>
|
</FieldRow>
|
||||||
</Subsection>
|
</Subsection>
|
||||||
<Subsection title="Statistics">
|
<Subsection title="Statistics">
|
||||||
{/* Row 1: Playbacks | Hammer Strikes | Warnings */}
|
<dl style={{ display: "grid", gridTemplateColumns: "repeat(3, 1fr)", gap: "0.75rem 2rem" }}>
|
||||||
<FieldRow columns={3}>
|
{/* Row 1: Playbacks | Hammer Strikes | Warnings */}
|
||||||
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
<Field label="Total Playbacks">{stats.totalPlaybacks}</Field>
|
||||||
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
<Field label="Total Hammer Strikes">{stats.totalHammerStrikes}</Field>
|
||||||
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
<Field label="Total Warnings Given">{stats.totalWarningsGiven}</Field>
|
||||||
</FieldRow>
|
{/* Row 2: Melodies | Favorites | (empty) */}
|
||||||
{/* Row 2: Melodies | Favorites */}
|
|
||||||
<FieldRow columns={3}>
|
|
||||||
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
<Field label="Total Melodies">{device.device_melodies_all?.length ?? 0}</Field>
|
||||||
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
<Field label="Favorite Melodies">{device.device_melodies_favorites?.length ?? 0}</Field>
|
||||||
<div />
|
<div />
|
||||||
</FieldRow>
|
</dl>
|
||||||
{stats.perBellStrikes?.length > 0 && (
|
{stats.perBellStrikes?.length > 0 && (
|
||||||
<div className="mt-2">
|
<div className="mt-3">
|
||||||
<Field label="Per Bell Strikes">
|
<Field label="Per Bell Strikes">
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
{stats.perBellStrikes.slice(0, attr.totalBells || stats.perBellStrikes.length).map((count, i) => (
|
||||||
|
|||||||
Reference in New Issue
Block a user