Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:45:30 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions
@@ -0,0 +1,504 @@
// frontend/src/pages/bellcloud/devices/tabs/BellsTab.jsx
// Bell Mechanisms tab — displays Bell Commander/Guard status, connected bell count,
// master switch, and per-bell glass cards with output, timing, size, and strike stats.
import { useState, useCallback } from 'react'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Icon from '@/components/ui/Icon'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import certificationImg from '@/assets/striker-mechanisms/certification.png'
import strikerSize1 from '@/assets/striker-mechanisms/size-1.jpg'
import strikerSize2 from '@/assets/striker-mechanisms/size-2.jpg'
import strikerSize3 from '@/assets/striker-mechanisms/size-3.jpg'
import strikerSize4 from '@/assets/striker-mechanisms/size-4.jpg'
import strikerSize5 from '@/assets/striker-mechanisms/size-5.jpg'
import strikerSize6 from '@/assets/striker-mechanisms/size-6.jpg'
const STRIKER_IMAGES = { 1: strikerSize1, 2: strikerSize2, 3: strikerSize3, 4: strikerSize4, 5: strikerSize5, 6: strikerSize6 }
// ─── Timing → Size mapping (from archive) ────────────────────────────────────
function getStrikerSize(ms) {
if (!Number.isFinite(ms)) return null
if (ms <= 89) return 1
if (ms <= 94) return 2
if (ms <= 99) return 3
if (ms <= 109) return 4
if (ms <= 119) return 5
return 6
}
const ORDINAL_NAMES = [
'First', 'Second', 'Third', 'Fourth', 'Fifth',
'Sixth', 'Seventh', 'Eighth', 'Ninth', 'Tenth',
]
// ─── Indicator pill ───────────────────────────────────────────────────────────
function Indicator({ label, value, yesLabel = 'Operational', noLabel = 'Disabled' }) {
const isUnknown = typeof value !== 'boolean'
const variant = isUnknown ? 'neutral' : value ? 'success' : 'danger'
const text = isUnknown ? 'Unknown' : value ? yesLabel : noLabel
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', alignItems: 'flex-start' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
}}>
{label}
</span>
<StatusBadge variant={variant}>{text}</StatusBadge>
</div>
)
}
// ─── Bell card (glass) ────────────────────────────────────────────────────────
const BELL_GLASS_CARD = {
background: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
display: 'flex',
flexDirection: 'column',
breakInside: 'avoid',
}
const SPEC_TILE = {
background: 'rgba(28, 32, 38, 0.40)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
borderRadius: 'var(--radius-md)',
padding: 'var(--space-3)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-1)',
textAlign: 'center',
flex: 1,
}
function BellCard({ index, output, hammingMs, strikeCount, requestingCounters, isBellSystems }) {
const ordinal = ORDINAL_NAMES[index] ?? `${index + 1}th`
const strikerSize = getStrikerSize(hammingMs)
const isDisabled = Number.isFinite(output) && Number(output) === 0
const [hovered, setHovered] = useState(false)
const strikerImg = strikerSize ? STRIKER_IMAGES[strikerSize] : null
return (
<article
style={{
...BELL_GLASS_CARD,
boxShadow: hovered
? 'var(--shadow-card), var(--shadow-lg), 0 0 0 1px rgba(192, 193, 255, 0.35), 0 0 32px rgba(192, 193, 255, 0.22)'
: 'var(--shadow-card), var(--shadow-md)',
transition: 'box-shadow 0.25s ease',
}}
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
>
{/* Hero image — fades into card body */}
<div style={{
position: 'relative',
height: '220px',
background: 'rgba(10, 14, 20, 0.80)',
overflow: 'hidden',
flexShrink: 0,
borderRadius: 'var(--radius-xl) var(--radius-xl) 0 0',
}}>
{strikerImg ? (
<img
src={strikerImg}
alt={`Size ${strikerSize} striker mechanism`}
style={{
width: '100%',
height: '100%',
objectFit: 'cover',
objectPosition: 'center bottom',
opacity: 1,
transform: hovered ? 'scale(1.15)' : 'scale(1.05)',
transition: 'transform 0.4s ease',
}}
/>
) : (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Icon name="bell" size={48} color="var(--color-text-muted)" />
</div>
)}
{/* Bottom fade — bleeds over the name below */}
<div style={{
position: 'absolute',
inset: 0,
background: 'linear-gradient(to top, rgba(28, 32, 38, 1) 0%, rgba(28, 32, 38, 0.55) 40%, transparent 70%)',
pointerEvents: 'none',
}} />
{/* Name overlaid at the bottom of the image */}
<div style={{
position: 'absolute',
bottom: 'var(--space-4)',
left: 'var(--space-4)',
right: 'var(--space-4)',
}}>
<h3 style={{
fontFamily: 'var(--font-family-display)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
lineHeight: 1.2,
margin: 0,
}}>
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)' }}>
{strikerSize ? 'Size ' : (isBellSystems ? 'Striker' : `${ordinal} Bell`)}
</span>
{strikerSize && (
<span style={{ fontSize: 'var(--font-size-xl)', fontWeight: 'var(--font-weight-bold)' }}>
{strikerSize}
</span>
)}
{strikerSize && (
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-medium)' }}>
{' Striker'}
</span>
)}
</h3>
<p style={{
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-muted)',
marginTop: '2px',
}}>
{ordinal} Bell
</p>
</div>
</div>
{/* Card body */}
<div style={{ padding: 'var(--space-4)', display: 'flex', flexDirection: 'column', gap: 'var(--space-4)', flex: 1 }}>
{/* Spec tiles: Output · Timing · Size */}
<div style={{ display: 'flex', gap: 'var(--space-2)' }}>
<div style={SPEC_TILE}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
Output
</span>
<span style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-bold)', color: isDisabled ? 'var(--color-danger)' : 'var(--color-text-secondary)' }}>
{isDisabled ? 'Disabled' : Number.isFinite(output) ? `Out ${output}` : '—'}
</span>
</div>
<div style={SPEC_TILE}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
Timing
</span>
<span style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-text-secondary)', fontFamily: 'var(--font-family-mono)' }}>
{Number.isFinite(hammingMs) ? `${hammingMs} ms` : '—'}
</span>
</div>
<div style={SPEC_TILE}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-muted)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)' }}>
Size
</span>
<span style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-bold)', color: 'var(--color-text-secondary)' }}>
{strikerSize ? `Size ${strikerSize}` : '—'}
</span>
</div>
</div>
{/* Strike count footer */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-more-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
opacity: 0.7,
}}>
Lifetime Strikes
</span>
{requestingCounters ? (
<span style={{ fontSize: 'var(--font-size-base)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>…</span>
) : Number.isFinite(strikeCount) ? (
<span style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)', fontFamily: 'var(--font-family-mono)' }}>
{strikeCount.toLocaleString()}
</span>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No data available
</span>
)}
</div>
{isBellSystems && (
<img
src={certificationImg}
alt="BellSystems Certified"
style={{ width: 36, height: 36, objectFit: 'contain', opacity: 0.90 }}
/>
)}
</div>
</div>
</article>
)
}
// ─── BellsTab ─────────────────────────────────────────────────────────────────
export default function BellsTab({
device,
attr,
canEdit,
loadDevice,
sendMqttCommand,
onEditBellOutputs,
}) {
const { toast } = useToast()
const [togglingCommander, setTogglingCommander] = useState(false)
const [requestingCounters, setRequestingCounters] = useState(false)
const [liveCounters] = useState(null)
const [confirmDisable, setConfirmDisable] = useState(false)
// Derived data
const bellOutputs = attr?.bellOutputs || []
const hammerTimings = attr?.hammerTimings || []
const totalBells = attr?.totalBells || 0
const hasBells = attr?.hasBells ?? false
const bellGuardOn = attr?.bellGuardOn ?? false
// Request strike counters via MQTT
const requestStrikeCounters = useCallback(async () => {
if (!sendMqttCommand) return
setRequestingCounters(true)
try {
await sendMqttCommand('system_info', { action: 'report_status' })
} finally {
// Will be cleared when live data arrives, or after timeout
setTimeout(() => setRequestingCounters(false), 8000)
}
}, [sendMqttCommand])
// Toggle Bell Commander (master switch)
const handleToggleCommander = async (newVal) => {
setTogglingCommander(true)
try {
await api.put(`/devices/${device.id || device.device_id}`, {
device_attributes: { hasBells: newVal },
})
await loadDevice()
toast.success(
newVal ? 'Bell Commander Enabled' : 'Bell Commander Disabled',
newVal ? 'All bell mechanisms are now active.' : 'All bell mechanisms have been disabled.'
)
} catch (err) {
toast.danger('Error', err.message || 'Failed to update Bell Commander.')
} finally {
setTogglingCommander(false)
}
}
const handleCommanderToggleRequest = () => {
if (hasBells) {
// Disabling — confirm first
setConfirmDisable(true)
} else {
handleToggleCommander(true)
}
}
const hasData = bellOutputs.length > 0
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ── Status overview bar ───────────────────────────────────────────── */}
<div style={{
background: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card)',
padding: 'var(--space-5) var(--space-6)',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: 'var(--space-6)',
}}>
{/* Indicators */}
<div style={{ display: 'flex', gap: 'var(--space-12)', flex: 1, flexWrap: 'wrap' }}>
<Indicator label="Bell Commander" value={hasBells} yesLabel="Operational" noLabel="Disabled" />
<Indicator label="Bell Guard" value={bellGuardOn} yesLabel="Operational" noLabel="Disabled" />
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)', alignItems: 'flex-start' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: 'var(--tracking-wide)',
}}>
Connected Bells
</span>
<span style={{
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-primary)',
}}>
{Number.isFinite(totalBells) ? (totalBells === 1 ? '1 Bell' : `${totalBells} Bells`) : '—'}
</span>
</div>
</div>
{/* Actions */}
{canEdit && (
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexShrink: 0 }}>
{/* Master toggle */}
<Button
variant={hasBells ? 'danger' : 'success'}
size="md"
loading={togglingCommander}
onClick={handleCommanderToggleRequest}
>
{hasBells ? 'Disable Commander' : 'Enable Commander'}
</Button>
{/* Edit Outputs */}
<Button variant="primary" size="md" onClick={onEditBellOutputs}>
Edit Outputs
</Button>
</div>
)}
</div>
{/* ── Bell cards ────────────────────────────────────────────────────── */}
{!hasData ? (
/* Empty state */
<div style={{
background: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
padding: 'var(--space-12)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 'var(--space-4)',
textAlign: 'center',
}}>
<div style={{
width: 56,
height: 56,
borderRadius: 'var(--radius-full)',
background: 'var(--color-bg-elevated)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
<Icon name="bell" size={24} color="var(--color-text-muted)" />
</div>
<div>
<p style={{ fontSize: 'var(--font-size-md)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)' }}>
No Bell Mechanisms Configured
</p>
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: 'var(--space-1)' }}>
Use Edit Outputs to assign bells and configure their output channels.
</p>
</div>
{canEdit && (
<Button variant="primary" size="sm" onClick={onEditBellOutputs}>
Configure Bell Outputs
</Button>
)}
</div>
) : (
<div>
{/* Strike counter refresh */}
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 'var(--space-4)',
}}>
<h2 style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-xl)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
}}>
Bell Mechanisms
</h2>
<Button
variant="ghost"
size="sm"
onClick={requestStrikeCounters}
loading={requestingCounters}
>
Refresh Strike Counts
</Button>
</div>
{/* Grid of bell cards */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(380px, 1fr))',
gap: 'var(--space-5)',
}}>
{bellOutputs.map((output, i) => (
<BellCard
key={i}
index={i}
output={output}
hammingMs={hammerTimings[i]}
strikeCount={liveCounters?.[i] ?? null}
requestingCounters={requestingCounters}
isBellSystems={true}
/>
))}
</div>
</div>
)}
{/* ── Confirm disable dialog ────────────────────────────────────────── */}
<ConfirmDialog
open={confirmDisable}
variant="danger"
title="Disable Bell Commander"
message="Are you sure you want to disable the Bell Commander? This will deactivate all bell mechanisms on this device."
confirmLabel="Disable"
onConfirm={() => {
setConfirmDisable(false)
handleToggleCommander(false)
}}
onCancel={() => setConfirmDisable(false)}
loading={togglingCommander}
/>
</div>
)
}
@@ -0,0 +1,643 @@
// frontend/src/pages/bellcloud/devices/tabs/ClockTab.jsx
import { useState, useEffect, useRef } from 'react'
import api from '@/lib/api'
import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import { useToast } from '@/components/ui/Toast'
import SyncClockModal from '@/modals/bellcloud/devices/SyncClockModal'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function extractTime(tsStr) {
if (!tsStr) return null
const d = new Date(
tsStr.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
)
if (!isNaN(d.getTime())) {
return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
}
const match = tsStr.match(/(\d{1,2}):(\d{2})/)
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null
}
function msToSeconds(ms) {
if (ms == null || ms === 0) return null
return `${(ms / 1000).toFixed(1)}s`
}
function bellLabel(n) {
if (!n || n === 0) return 'Disabled'
return `Bell ${n}`
}
function outputLabel(n) {
if (!n || n === 0) return 'Disabled'
return `Output ${n}`
}
function silenceLabel(isOn, from, to) {
if (!isOn) return 'Off'
const f = extractTime(from)
const t = extractTime(to)
if (f && t) return `${f} – ${t}`
return 'On'
}
// ─── Analog Clock Face ────────────────────────────────────────────────────────
function AnalogClock({ time, label, variant = 'primary' }) {
const canvasRef = useRef(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext('2d')
const size = canvas.width
const cx = size / 2
const cy = size / 2
const r = size / 2 - 4
const h = time.getHours() % 12
const m = time.getMinutes()
const s = time.getSeconds()
const isPrimary = variant === 'primary'
const accentColor = isPrimary ? 'rgba(192,193,255,0.9)' : 'rgba(192,193,255,0.35)'
const faceColor = isPrimary ? 'rgba(192,193,255,0.06)' : 'rgba(192,193,255,0.03)'
const rimColor = isPrimary ? 'rgba(192,193,255,0.25)' : 'rgba(192,193,255,0.10)'
const tickColor = isPrimary ? 'rgba(192,193,255,0.30)' : 'rgba(192,193,255,0.12)'
const hourHandColor = isPrimary ? 'rgba(192,193,255,1)' : 'rgba(192,193,255,0.45)'
const minuteHandColor = isPrimary ? 'rgba(210,187,255,0.85)' : 'rgba(210,187,255,0.35)'
const secondHandColor = isPrimary ? 'rgba(255,92,92,0.85)' : 'rgba(255,92,92,0.4)'
const centerDotColor = isPrimary ? '#c0c1ff' : 'rgba(192,193,255,0.4)'
ctx.clearRect(0, 0, size, size)
// Face
ctx.beginPath()
ctx.arc(cx, cy, r, 0, Math.PI * 2)
ctx.fillStyle = faceColor
ctx.fill()
// Outer glow for primary
if (isPrimary) {
const grad = ctx.createRadialGradient(cx, cy, r - 2, cx, cy, r + 8)
grad.addColorStop(0, 'rgba(192,193,255,0.12)')
grad.addColorStop(1, 'rgba(192,193,255,0)')
ctx.beginPath()
ctx.arc(cx, cy, r + 4, 0, Math.PI * 2)
ctx.fillStyle = grad
ctx.fill()
}
// Rim
ctx.beginPath()
ctx.arc(cx, cy, r, 0, Math.PI * 2)
ctx.strokeStyle = rimColor
ctx.lineWidth = isPrimary ? 1.5 : 1
ctx.stroke()
// Ticks
for (let i = 0; i < 60; i++) {
const angle = (i / 60) * Math.PI * 2 - Math.PI / 2
const isHour = i % 5 === 0
const inner = isHour ? r - 12 : r - 7
ctx.beginPath()
ctx.moveTo(cx + Math.cos(angle) * inner, cy + Math.sin(angle) * inner)
ctx.lineTo(cx + Math.cos(angle) * (r - 2), cy + Math.sin(angle) * (r - 2))
ctx.strokeStyle = isHour ? accentColor : tickColor
ctx.lineWidth = isHour ? 1.5 : 0.75
ctx.stroke()
}
// Hour hand
const hourAngle = ((h + m / 60) / 12) * Math.PI * 2 - Math.PI / 2
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(hourAngle) * r * 0.52, cy + Math.sin(hourAngle) * r * 0.52)
ctx.strokeStyle = hourHandColor
ctx.lineWidth = isPrimary ? 3 : 2
ctx.lineCap = 'round'
ctx.stroke()
// Minute hand
const minAngle = ((m + s / 60) / 60) * Math.PI * 2 - Math.PI / 2
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(minAngle) * r * 0.72, cy + Math.sin(minAngle) * r * 0.72)
ctx.strokeStyle = minuteHandColor
ctx.lineWidth = isPrimary ? 2 : 1.5
ctx.lineCap = 'round'
ctx.stroke()
// Second hand
const secAngle = (s / 60) * Math.PI * 2 - Math.PI / 2
ctx.beginPath()
ctx.moveTo(cx, cy)
ctx.lineTo(cx + Math.cos(secAngle) * r * 0.82, cy + Math.sin(secAngle) * r * 0.82)
ctx.strokeStyle = secondHandColor
ctx.lineWidth = 1
ctx.lineCap = 'round'
ctx.stroke()
// Center dot
ctx.beginPath()
ctx.arc(cx, cy, isPrimary ? 4 : 3, 0, Math.PI * 2)
ctx.fillStyle = centerDotColor
ctx.fill()
}, [time, variant])
const digitalTime = `${time.getHours().toString().padStart(2, '0')}:${time.getMinutes().toString().padStart(2, '0')}:${time.getSeconds().toString().padStart(2, '0')}`
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 'var(--space-3)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
color: variant === 'primary' ? 'var(--color-primary)' : 'var(--color-text-muted)',
}}>
{label}
</span>
<canvas
ref={canvasRef}
width={160}
height={160}
style={{ display: 'block' }}
aria-label={`${label}: ${digitalTime}`}
/>
<div style={{
fontFamily: 'var(--font-family-mono)',
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-medium)',
color: variant === 'primary' ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
letterSpacing: '0.04em',
}}>
{digitalTime}
</div>
</div>
)
}
// ─── SettingRow ───────────────────────────────────────────────────────────────
function SettingRow({ label, value, mono = false, badge }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-3)', minHeight: 32 }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', flexShrink: 0 }}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
color: value === '—' || value === 'Disabled' || value === 'Off' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
fontWeight: 'var(--font-weight-medium)',
textAlign: 'right',
}}>
{badge ? badge : value ?? '—'}
</span>
</div>
)
}
function Divider() {
return (
<div style={{ height: 1, backgroundColor: 'var(--color-border)', margin: 'var(--space-1) 0' }} />
)
}
// ─── HeroTile ─────────────────────────────────────────────────────────────────
function HeroTile({ label, value, flash = false }) {
return (
<div style={{
backgroundColor: 'rgba(28, 32, 38, 0.40)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: `1px solid ${flash ? 'var(--color-warning)' : 'var(--color-border)'}`,
borderRadius: 'var(--radius-lg)',
padding: 'var(--space-3) var(--space-4)',
display: 'flex',
flexDirection: 'column',
gap: 'var(--space-1)',
transition: 'border-color 0.3s',
animation: flash ? 'tile-flash 0.6s ease 3' : 'none',
}}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
color: flash ? 'var(--color-warning)' : 'var(--color-text-muted)',
transition: 'color 0.3s',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-xl)',
fontWeight: 'var(--font-weight-bold)',
color: value ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
fontFamily: 'var(--font-family-display)',
letterSpacing: 'var(--tracking-tight)',
lineHeight: 1.1,
fontStyle: value ? 'normal' : 'italic',
}}>
{value ?? 'not set'}
</span>
</div>
)
}
// ─── ClockTab ─────────────────────────────────────────────────────────────────
export default function ClockTab({
device,
canEdit,
clock,
attr,
onEditClockSettings,
onEditAlerts,
onEditBacklight,
onDeviceUpdated,
}) {
const [now, setNow] = useState(new Date())
const [showSync, setShowSync] = useState(false)
const [flashFields, setFlashFields] = useState(false)
const [togglingClock, setTogglingClock] = useState(false)
const { toast } = useToast()
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id)
}, [])
// ── Derived values ────────────────────────────────────────────────────────
const hasClock = attr?.hasClock ?? false
const clockOutputs = clock?.clockOutputs || []
const clockTimings = clock?.clockTimings || []
const oddOut = clockOutputs[0] || 0
const evenOut = clockOutputs[1] || 0
const runPulse = clockTimings[0]
const pauseTime = clockTimings[1]
const alertType = clock?.ringAlerts || 'disabled'
const alertMasterOn = clock?.ringAlertsMasterOn ?? false
const ringIntervals = clock?.ringIntervals
const hourBell = clock?.hourAlertsBell || 0
const halfBell = clock?.halfhourAlertsBell || 0
const quarterBell = clock?.quarterAlertsBell || 0
const isDaySilenceOn = clock?.isDaySilenceOn ?? false
const isNightSilenceOn = clock?.isNightSilenceOn ?? false
const backlightOn = clock?.isBacklightAutomationOn ?? false
const backlightOutput = clock?.backlightOutput || 0
const backlightOnTime = extractTime(clock?.backlightTurnOnTime)
const backlightOffTime = extractTime(clock?.backlightTurnOffTime)
const timezone = attr?.timezone || device?.timezone || null
const ntpServer = attr?.ntpServer || attr?.networkSettings?.ntpServer || null
const autoDst = attr?.autoDST ?? false
const alertTypeLabel =
alertType === 'single' ? 'Single Fire' :
alertType === 'multi' ? 'Hour Indicating' :
'Disabled'
const alertVariant = alertMasterOn ? 'success' : 'neutral'
// All 4 hero fields must be set before ENABLING the master switch
const allFieldsSet = oddOut > 0 && evenOut > 0 && runPulse > 0 && pauseTime > 0
const handleMasterToggleClick = async () => {
if (!canEdit || togglingClock) return
// Trying to enable but required fields are missing — flash tiles and block
if (!hasClock && !allFieldsSet) {
setFlashFields(true)
setTimeout(() => setFlashFields(false), 2200)
return
}
const deviceId = device?.id || device?.device_id
const newValue = !hasClock
setTogglingClock(true)
try {
const updated = await api.put(`/devices/${deviceId}`, {
device_attributes: { ...(attr || {}), hasClock: newValue },
})
if (onDeviceUpdated) {
// If API returns the full device object, use it; otherwise patch manually
if (updated?.device_attributes !== undefined) {
onDeviceUpdated(updated)
} else {
onDeviceUpdated(prev => ({
...prev,
device_attributes: { ...(prev?.device_attributes || {}), hasClock: newValue },
}))
}
}
toast.success(newValue ? 'Clock enabled' : 'Clock disabled', '')
} catch (err) {
toast.danger('Error', err.message || 'Failed to update clock status.')
} finally {
setTogglingClock(false)
}
}
// Backlight period string
const backlightPeriod = backlightOn && backlightOnTime && backlightOffTime
? `${backlightOnTime} – ${backlightOffTime}`
: null
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
{/* Flash keyframe injected once */}
<style>{`
@keyframes tile-flash {
0%, 100% { border-color: var(--color-border); }
50% { border-color: var(--color-warning); box-shadow: 0 0 0 2px rgba(251,191,36,0.18); }
}
`}</style>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ── Hero ──────────────────────────────────────────────────────────── */}
<div style={{
position: 'relative',
borderRadius: 'var(--radius-xl)',
backgroundColor: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
overflow: 'hidden',
padding: 'var(--space-6) var(--space-8)',
}}>
<div aria-hidden="true" style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: 'radial-gradient(ellipse 50% 80% at 85% 50%, rgba(192,193,255,0.05) 0%, transparent 70%)',
}} />
<div style={{
position: 'relative',
display: 'flex',
alignItems: 'center',
gap: 'var(--space-8)',
flexWrap: 'wrap',
}}>
{/* Left — settings panel */}
<div style={{ flex: 1, minWidth: 260, display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{/* Title + toggle */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<span style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-xl)',
fontWeight: 'var(--font-weight-bold)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
}}>
Master Control
</span>
<button
type="button"
role="switch"
aria-checked={hasClock}
aria-label="Toggle clock"
onClick={handleMasterToggleClick}
disabled={togglingClock}
style={{
position: 'relative',
width: 36,
height: 20,
borderRadius: 'var(--radius-full)',
backgroundColor: hasClock ? 'var(--color-success)' : 'var(--color-bg-island)',
border: `1px solid ${hasClock ? 'var(--color-success)' : 'var(--color-border-strong)'}`,
flexShrink: 0,
cursor: canEdit && !togglingClock ? 'pointer' : 'default',
transition: 'background-color 0.2s, border-color 0.2s',
padding: 0,
opacity: togglingClock ? 0.6 : 1,
}}
>
<div style={{
position: 'absolute',
top: 2,
left: hasClock ? 17 : 2,
width: 14,
height: 14,
borderRadius: 'var(--radius-full)',
backgroundColor: hasClock ? 'var(--color-text-inverse)' : 'var(--color-text-muted)',
transition: 'left 0.2s',
}} />
</button>
<span style={{
fontSize: 'var(--font-size-sm)',
color: hasClock ? 'var(--color-success)' : 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-medium)',
}}>
{hasClock ? 'Active' : 'Inactive'}
</span>
</div>
{/* 2×2 tile grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-3)' }}>
<HeroTile label="Pulse Duration" value={msToSeconds(runPulse)} flash={flashFields && !runPulse} />
<HeroTile label="Pause Duration" value={msToSeconds(pauseTime)} flash={flashFields && !pauseTime} />
<HeroTile label="ODD Output" value={oddOut ? outputLabel(oddOut) : null} flash={flashFields && !oddOut} />
<HeroTile label="EVEN Output" value={evenOut ? outputLabel(evenOut) : null} flash={flashFields && !evenOut} />
</div>
{/* Actions row */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
{canEdit && (
<Button variant="ghost" size="sm" onClick={onEditClockSettings}>Edit Settings</Button>
)}
<button
type="button"
onClick={() => setShowSync(true)}
style={{
background: 'none', border: 'none',
padding: '0 var(--space-1)',
fontSize: 'var(--font-size-sm)',
color: 'var(--color-primary)',
cursor: 'pointer',
opacity: 0.75,
transition: 'opacity 0.15s',
}}
onMouseEnter={e => (e.currentTarget.style.opacity = 1)}
onMouseLeave={e => (e.currentTarget.style.opacity = 0.75)}
>
Sync Clocktower Time
</button>
</div>
</div>
{/* Right — clocks, with equal breathing room on all sides */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 'var(--space-12)', padding: '0 var(--space-8)' }}>
<AnalogClock time={now} label="RTC Time" variant="primary" />
<AnalogClock time={now} label="Clocktower" variant="primary" />
</div>
</div>
</div>
{/* ── Cards grid — 3 columns ────────────────────────────────────────── */}
<div className="masonry-grid masonry-grid--3">
{/* Alert Settings */}
<Card
title="Alert Settings"
action={canEdit && (
<Button variant="ghost" size="sm" onClick={onEditAlerts}>Edit</Button>
)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{/* Master + type inline */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
<StatusBadge variant={alertVariant}>{alertMasterOn ? 'Enabled' : 'Disabled'}</StatusBadge>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
{alertTypeLabel}
</span>
{alertType === 'multi' && ringIntervals && (
<>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>·</span>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>
Tempo {msToSeconds(ringIntervals)}
</span>
</>
)}
</div>
<Divider />
{/* Bell assignments + silence periods — shared 3-column grid */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 'var(--space-2) var(--space-2)', rowGap: 'var(--space-4)' }}>
{[
{ label: 'Hours', value: bellLabel(hourBell) },
{ label: 'Half-Hours', value: bellLabel(halfBell) },
{ label: 'Quarters', value: bellLabel(quarterBell) },
].map(({ label, value }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-semibold)',
color: value === 'Disabled' ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
fontFamily: 'var(--font-family-display)',
letterSpacing: 'var(--tracking-tight)',
}}>
{value}
</span>
</div>
))}
{/* Silence periods in cols 1–2, col 3 intentionally empty */}
{[
{ label: 'Day Silence', isOn: isDaySilenceOn, from: clock?.daySilenceFrom, to: clock?.daySilenceTo },
{ label: 'Night Silence', isOn: isNightSilenceOn, from: clock?.nightSilenceFrom, to: clock?.nightSilenceTo },
].map(({ label, isOn, from, to }) => (
<div key={label} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-semibold)',
color: isOn ? 'var(--color-text-primary)' : 'var(--color-text-muted)',
fontFamily: isOn ? 'var(--font-family-mono)' : 'inherit',
letterSpacing: isOn ? '0.03em' : 0,
}}>
{silenceLabel(isOn, from, to)}
</span>
</div>
))}
</div>
</div>
</Card>
{/* Backlight Automation */}
<Card
title="Backlight Automation"
action={canEdit && (
<Button variant="ghost" size="sm" onClick={onEditBacklight}>Edit</Button>
)}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<SettingRow
label="Automation"
badge={<StatusBadge variant={backlightOn ? 'success' : 'neutral'}>{backlightOn ? 'Automated' : 'Disabled'}</StatusBadge>}
/>
<SettingRow label="Output" value={outputLabel(backlightOutput)} />
{backlightOn && (
<SettingRow
label="Period"
value={backlightPeriod || '—'}
mono={!!backlightPeriod}
/>
)}
</div>
</Card>
{/* Network & Time */}
<Card title="Network & Time">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<SettingRow label="Timezone" value={timezone || '—'} mono={!!timezone} />
<SettingRow label="NTP Server" value={ntpServer || '—'} mono={!!ntpServer} />
<SettingRow
label="Auto-DST"
badge={
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<StatusBadge variant="neutral">{autoDst ? 'Enabled' : 'Disabled'}</StatusBadge>
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
backgroundColor: 'rgba(28, 32, 38, 0.40)',
padding: '2px var(--space-2)',
borderRadius: 'var(--radius-sm)',
border: '1px solid var(--color-border)',
}}>
Coming Soon
</span>
</div>
}
/>
</div>
{canEdit && (
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
<p style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Timezone and NTP configuration are managed via device attributes. Auto-DST adjustment is pending implementation.
</p>
</div>
)}
</Card>
</div>
</div>
<SyncClockModal open={showSync} onClose={() => setShowSync(false)} device={device} />
</>
)
}
@@ -0,0 +1,5 @@
// frontend/src/pages/bellcloud/devices/tabs/ControlTab.jsx
export default function ControlTab({ device, canEdit, onDeviceUpdated }) {
return null
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,683 @@
// frontend/src/pages/bellcloud/devices/tabs/ManageTab.jsx
// Manage tab — Device Users + Device Notes + Linked Issues
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import api from '@/lib/api'
import { useToast } from '@/components/ui/Toast'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import Icon from '@/components/ui/Icon'
import { fmtDateMedium, fmtRelative } from '@/lib/formatters'
import AddDeviceUserModal from '@/modals/bellcloud/devices/AddDeviceUserModal'
import DeviceNoteModal from '@/modals/bellcloud/devices/DeviceNoteModal'
import EntryFormModal from '@/modals/crm/helpdesk/EntryFormModal'
// ─── Visual constants (match OverviewTab's GlassCard aesthetic) ───────────────
const GLASS = 'rgba(28, 32, 38, 0.30)'
const GLASS_INNER = 'rgba(28, 32, 38, 0.40)'
const GLASS_HOVER = 'rgba(49, 53, 60, 0.55)'
const BLUR = 'blur(12px)'
// ─── Status/severity maps (match IssuesTab constants) ─────────────────────────
const STATUS_META = {
open: { label: 'Open', color: 'var(--color-danger)', bg: 'var(--color-danger-bg)' },
researching: { label: 'Researching', color: 'var(--color-warning)', bg: 'var(--color-warning-bg)' },
resolved: { label: 'Resolved', color: 'var(--color-success)', bg: 'var(--color-success-bg)' },
}
const SEVERITY_META = {
low: { label: 'Low', color: 'var(--color-info)' },
medium: { label: 'Medium', color: 'var(--color-warning)' },
high: { label: 'High', color: 'var(--color-danger)' },
critical: { label: 'Critical', color: 'var(--color-danger)', breathe: true },
}
// ─── Sub-components ───────────────────────────────────────────────────────────
function SectionLabel({ children }) {
return (
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-muted)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
}}>
{children}
</span>
)
}
function GlassCard({ children, style = {} }) {
return (
<div style={{
background: GLASS,
backdropFilter: BLUR,
WebkitBackdropFilter: BLUR,
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-xl)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
...style,
}}>
{children}
</div>
)
}
function CardHeader({ label, count, action }) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: 'var(--space-4) var(--space-5)',
borderBottom: '1px solid var(--color-border)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<SectionLabel>{label}</SectionLabel>
{count != null && (
<span style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
background: GLASS_INNER,
padding: '2px var(--space-2)',
borderRadius: 'var(--radius-full)',
border: '1px solid var(--color-border)',
lineHeight: 1.4,
}}>
{count}
</span>
)}
</div>
{action}
</div>
)
}
function EmptySlate({ icon, message, action }) {
return (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-8) var(--space-6)',
color: 'var(--color-text-muted)',
}}>
<div style={{ opacity: 0.35 }}>{icon}</div>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', textAlign: 'center' }}>{message}</p>
{action}
</div>
)
}
function StatusPill({ status }) {
const m = STATUS_META[status] || { label: status, color: 'var(--color-text-muted)', bg: 'var(--color-bg-elevated)' }
return (
<span style={{
display: 'inline-flex', alignItems: 'center', gap: 5,
padding: '2px 8px',
borderRadius: 'var(--radius-full)',
backgroundColor: m.bg,
border: `1px solid ${m.color}33`,
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: m.color,
whiteSpace: 'nowrap',
flexShrink: 0,
}}>
<span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: m.color, flexShrink: 0 }} />
{m.label}
</span>
)
}
function SeverityBars({ severity }) {
if (!severity) return null
const m = SEVERITY_META[severity] || { label: severity, color: 'var(--color-text-muted)' }
const bars = { low: 1, medium: 2, high: 3, critical: 4 }[severity] || 1
return (
<span
className={m.breathe ? 'severity-critical' : undefined}
style={{ display: 'inline-flex', alignItems: 'center', gap: 4, flexShrink: 0 }}
>
<span style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 12 }}>
{[1, 2, 3, 4].map(b => (
<span key={b} style={{
width: 3,
height: 2 + b * 2,
borderRadius: 1,
backgroundColor: b <= bars ? m.color : 'var(--color-bg-island)',
display: 'block',
}} />
))}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-medium)', color: m.color }}>
{m.label}
</span>
</span>
)
}
// ─── ManageTab ────────────────────────────────────────────────────────────────
export default function ManageTab({ device, canEdit, deviceUsers: propUsers, usersLoading: propUsersLoading }) {
const navigate = useNavigate()
const { toast } = useToast()
const id = device?.id || device?.device_id
// ── Users state ────────────────────────────────────────────────────────────
// We manage our own copy so we can add/remove without refetching the entire device
const [users, setUsers] = useState(propUsers || [])
const [usersLoading, setUsersLoading] = useState(propUsersLoading || false)
const [removingUser, setRemovingUser] = useState(null)
const [confirmRemoveUser, setConfirmRemoveUser] = useState(null)
const [showAddUser, setShowAddUser] = useState(false)
// Sync from parent prop on mount / prop change
useEffect(() => {
if (propUsers) setUsers(propUsers)
}, [propUsers])
useEffect(() => {
setUsersLoading(propUsersLoading)
}, [propUsersLoading])
const reloadUsers = useCallback(async () => {
if (!id) return
setUsersLoading(true)
try {
const data = await api.get(`/devices/${id}/users`)
setUsers(data.users || [])
} catch {
// silent — parent has already shown this device's users
} finally {
setUsersLoading(false)
}
}, [id])
const handleUserAdded = (user) => {
setUsers(prev => [...prev, user])
toast.success('User added', `${user.display_name || user.email} now has access.`)
}
const handleRemoveUser = async () => {
if (!confirmRemoveUser) return
const targetId = confirmRemoveUser.user_id || confirmRemoveUser.id
setRemovingUser(targetId)
try {
await api.delete(`/devices/${id}/user-list/${targetId}`)
setUsers(prev => prev.filter(u => (u.user_id || u.id) !== targetId))
toast.success('Removed', `${confirmRemoveUser.display_name || confirmRemoveUser.email} removed.`)
} catch (err) {
toast.danger('Error', err.message || 'Failed to remove user.')
} finally {
setRemovingUser(null)
setConfirmRemoveUser(null)
}
}
// ── Device notes (Firestore) ──────────────────────────────────────────────
const [notes, setNotes] = useState([])
const [notesLoading, setNotesLoading] = useState(false)
const [noteModal, setNoteModal] = useState({ open: false, note: null })
const [deletingNote, setDeletingNote] = useState(null)
const [confirmDeleteNote, setConfirmDeleteNote] = useState(null)
const loadNotes = useCallback(async () => {
if (!id) return
setNotesLoading(true)
try {
const data = await api.get(`/devices/${id}/notes`)
setNotes((data.notes || []).slice().reverse()) // newest first
} catch {
setNotes([])
} finally {
setNotesLoading(false)
}
}, [id])
useEffect(() => { loadNotes() }, [loadNotes])
const handleNoteSaved = () => {
setNoteModal({ open: false, note: null })
loadNotes()
}
const handleDeleteNote = async () => {
if (!confirmDeleteNote) return
setDeletingNote(confirmDeleteNote.id)
try {
await api.delete(`/devices/${id}/notes/${confirmDeleteNote.id}`)
setNotes(prev => prev.filter(n => n.id !== confirmDeleteNote.id))
toast.success('Deleted', 'Note removed.')
} catch (err) {
toast.danger('Error', err.message || 'Failed to delete note.')
} finally {
setDeletingNote(null)
setConfirmDeleteNote(null)
}
}
// ── Linked issues (global /notes system, entity_type=device) ─────────────
const [issues, setIssues] = useState([])
const [issuesLoading, setIssuesLoading] = useState(false)
const [issueModal, setIssueModal] = useState({ open: false, entry: null })
const [confirmDeleteIssue, setConfirmDeleteIssue] = useState(null)
const [deletingIssue, setDeletingIssue] = useState(false)
const loadIssues = useCallback(async () => {
if (!id) return
setIssuesLoading(true)
try {
const data = await api.get(`/notes/by-entity/device/${id}`)
setIssues(Array.isArray(data) ? data : [])
} catch {
setIssues([])
} finally {
setIssuesLoading(false)
}
}, [id])
useEffect(() => { loadIssues() }, [loadIssues])
const openNewIssue = () => setIssueModal({ open: true, entry: null })
const openEditIssue = (entry) => setIssueModal({ open: true, entry })
const handleIssueSaved = () => {
setIssueModal({ open: false, entry: null })
loadIssues()
}
const handleDeleteIssue = async () => {
if (!confirmDeleteIssue) return
setDeletingIssue(true)
try {
await api.delete(`/notes/${confirmDeleteIssue}`)
toast.success('Deleted', 'Issue removed.')
setConfirmDeleteIssue(null)
loadIssues()
} catch (err) {
toast.danger('Error', err.message || 'Failed to delete issue.')
} finally {
setDeletingIssue(false)
}
}
const sn = device?.serial_number || device?.device_id
const existingUserIds = users.map(u => u.user_id || u.id).filter(Boolean)
// ── Render ─────────────────────────────────────────────────────────────────
return (
<>
<style>{`
.manage-user-row:hover .manage-user-remove { opacity: 1; }
.manage-user-row .manage-user-remove { opacity: 0; transition: opacity 0.15s; }
.manage-issue-row { cursor: pointer; transition: background 0.12s; }
.manage-issue-row:hover { background: ${GLASS_HOVER} !important; }
.manage-note-card { transition: box-shadow 0.15s; }
.manage-note-card:hover { box-shadow: var(--shadow-md) !important; }
@keyframes severity-critical-glow {
0%, 100% { filter: drop-shadow(0 0 0px var(--color-danger)); }
50% { filter: drop-shadow(0 0 5px var(--color-danger)); }
}
.severity-critical { animation: severity-critical-glow 0.9s ease-in-out infinite; }
`}</style>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* ── 1. DEVICE ACCESS (Users) ──────────────────────────────────────── */}
<GlassCard>
<CardHeader
label="Device Access"
count={users.length || undefined}
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>
+ Add User
</Button>
)}
/>
<div style={{ padding: 'var(--space-4) var(--space-5)', display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{usersLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-6)' }}>
<Spinner size="sm" />
</div>
) : users.length === 0 ? (
<EmptySlate
icon={
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
}
message="No users have access to this device."
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setShowAddUser(true)}>Add first user</Button>
)}
/>
) : (
users.map((user) => {
const userId = user.user_id || user.id
const initials = (user.display_name || user.email || '?').charAt(0).toUpperCase()
return (
<div
key={userId}
className="manage-user-row"
style={{
display: 'flex', alignItems: 'center', gap: 'var(--space-3)',
padding: 'var(--space-3)',
borderRadius: 'var(--radius-md)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
cursor: userId ? 'pointer' : 'default',
transition: 'background 0.15s',
}}
onClick={() => userId && navigate(`/users/${userId}`)}
onMouseEnter={e => e.currentTarget.style.background = GLASS_HOVER}
onMouseLeave={e => e.currentTarget.style.background = GLASS_INNER}
>
<div style={{
width: 36, height: 36, borderRadius: '50%', overflow: 'hidden',
background: 'var(--color-primary-subtle)', flexShrink: 0,
display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-primary)', border: '1px solid rgba(192,193,255,0.20)',
}}>
{user.photo_url ? <img src={user.photo_url} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover' }} /> : initials}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user.display_name || user.email || 'Unknown User'}
</p>
{user.email && user.display_name && (
<p style={{ margin: 0, fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{user.email}
</p>
)}
</div>
{user.role && <StatusBadge variant="info">{user.role}</StatusBadge>}
{canEdit && (
<button
className="manage-user-remove"
onClick={e => { e.stopPropagation(); setConfirmRemoveUser(user) }}
disabled={removingUser === userId}
title="Remove access" aria-label="Remove user"
style={{
width: 28, height: 28, borderRadius: 'var(--radius-md)',
border: '1px solid var(--color-border-strong)',
background: 'var(--color-danger-bg)', color: 'var(--color-danger)',
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0, transition: 'background 0.12s',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--color-danger)'}
onMouseLeave={e => e.currentTarget.style.background = 'var(--color-danger-bg)'}
>
<Icon name="delete" size={13} />
</button>
)}
</div>
)
})
)}
</div>
</GlassCard>
{/* ── 2. DEVICE NOTES ───────────────────────────────────────────────── */}
<GlassCard>
<CardHeader
label="Device Notes"
count={notes.length || undefined}
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
+ Add Note
</Button>
)}
/>
<div style={{ padding: notesLoading || notes.length === 0 ? 0 : 'var(--space-4) var(--space-5)' }}>
{notesLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
<Spinner size="sm" />
</div>
) : notes.length === 0 ? (
<EmptySlate
icon={
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
<line x1="16" y1="13" x2="8" y2="13"/>
<line x1="16" y1="17" x2="8" y2="17"/>
<polyline points="10 9 9 9 8 9"/>
</svg>
}
message="No notes recorded for this device yet."
action={canEdit && (
<Button variant="secondary" size="sm" onClick={() => setNoteModal({ open: true, note: null })}>
Add first note
</Button>
)}
/>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: 'var(--space-3)' }}>
{notes.map(note => (
<div
key={note.id}
className="manage-note-card"
style={{
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
padding: 'var(--space-4)',
borderRadius: 'var(--radius-lg)',
background: GLASS_INNER,
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-sm)',
}}
>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', lineHeight: 1.6, whiteSpace: 'pre-wrap', flex: 1 }}>
{note.content || '—'}
</p>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-2)' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 }}>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }} title={fmtDateMedium(note.created_at)}>
{fmtRelative(note.created_at)}
</span>
{note.created_by && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{note.created_by}
</span>
)}
</div>
{canEdit && (
<div style={{ display: 'flex', gap: 'var(--space-1)', flexShrink: 0 }}>
<button onClick={() => setNoteModal({ open: true, note })} title="Edit note" aria-label="Edit note"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-primary)'; e.currentTarget.style.background = 'var(--color-primary-subtle)'; e.currentTarget.style.borderColor = 'rgba(192,193,255,0.25)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="edit" size={13} />
</button>
<button onClick={() => setConfirmDeleteNote(note)} title="Delete note" aria-label="Delete note"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="delete" size={13} />
</button>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
</GlassCard>
{/* ── 3. LINKED ISSUES ──────────────────────────────────────────────── */}
<GlassCard>
<CardHeader
label="Linked Issues"
count={
issues.filter(i => i.status !== 'resolved').length > 0
? `${issues.filter(i => i.status !== 'resolved').length} open`
: issues.length > 0 ? `${issues.length} total` : undefined
}
action={canEdit && (
<Button variant="secondary" size="sm" onClick={openNewIssue}>
+ New Issue
</Button>
)}
/>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{issuesLoading ? (
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-8)' }}>
<Spinner size="sm" />
</div>
) : issues.length === 0 ? (
<EmptySlate
icon={
<svg width="36" height="36" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
</svg>
}
message="No issues linked to this device."
action={canEdit && (
<Button variant="secondary" size="sm" onClick={openNewIssue}>Log first issue</Button>
)}
/>
) : (
issues.map((issue, idx) => (
<div
key={issue.id}
className="manage-issue-row"
style={{
display: 'grid',
gridTemplateColumns: 'auto 1fr auto auto auto',
alignItems: 'center',
gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-5)',
borderBottom: idx < issues.length - 1 ? '1px solid var(--color-border)' : 'none',
}}
onClick={() => canEdit && openEditIssue(issue)}
>
<StatusPill status={issue.status} />
<div style={{ minWidth: 0 }}>
<p style={{ margin: 0, fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-medium)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{issue.title}
</p>
{issue.body && (
<p style={{ margin: '2px 0 0', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{issue.body.replace(/<[^>]*>/g, '').slice(0, 90)}
</p>
)}
</div>
<SeverityBars severity={issue.severity} />
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', whiteSpace: 'nowrap', flexShrink: 0 }} title={fmtDateMedium(issue.created_at)}>
{fmtRelative(issue.created_at)}
</span>
{canEdit && (
<button
onClick={e => { e.stopPropagation(); setConfirmDeleteIssue(issue.id) }}
title="Delete issue" aria-label="Delete issue"
style={{ width: 26, height: 26, borderRadius: 'var(--radius-sm)', border: '1px solid transparent', background: 'none', color: 'var(--color-text-muted)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0, transition: 'color 0.12s, background 0.12s, border-color 0.12s' }}
onMouseEnter={e => { e.currentTarget.style.color = 'var(--color-danger)'; e.currentTarget.style.background = 'var(--color-danger-bg)'; e.currentTarget.style.borderColor = 'var(--color-danger)' }}
onMouseLeave={e => { e.currentTarget.style.color = 'var(--color-text-muted)'; e.currentTarget.style.background = 'none'; e.currentTarget.style.borderColor = 'transparent' }}
>
<Icon name="delete" size={13} />
</button>
)}
</div>
))
)}
</div>
</GlassCard>
</div>
{/* ── Modals ─────────────────────────────────────────────────────────── */}
<AddDeviceUserModal
open={showAddUser}
deviceId={id}
existingUserIds={existingUserIds}
onClose={() => setShowAddUser(false)}
onAdded={handleUserAdded}
/>
<DeviceNoteModal
open={noteModal.open}
deviceId={id}
note={noteModal.note}
onClose={() => setNoteModal({ open: false, note: null })}
onSaved={handleNoteSaved}
/>
{/* Issue form — pre-link to this device (locked — cannot be removed) */}
<EntryFormModal
open={issueModal.open}
entry={issueModal.entry}
defaultType="issue"
prefilledLinks={issueModal.entry
? undefined
: [{ entity_type: 'device', entity_id: id, display_name: device?.device_name || sn, locked: true }]
}
knownEntities={id ? { [id]: device?.device_name || sn } : undefined}
onClose={() => setIssueModal({ open: false, entry: null })}
onSaved={handleIssueSaved}
onDelete={canEdit ? (entryId) => {
setIssueModal({ open: false, entry: null })
setConfirmDeleteIssue(entryId)
} : null}
/>
{/* Confirm: remove user */}
<ConfirmDialog
open={!!confirmRemoveUser}
variant="danger"
title="Remove Access"
message={`Remove ${confirmRemoveUser?.display_name || confirmRemoveUser?.email || 'this user'} from the device?`}
confirmLabel="Remove"
onConfirm={handleRemoveUser}
onClose={() => setConfirmRemoveUser(null)}
loading={!!removingUser}
/>
{/* Confirm: delete note */}
<ConfirmDialog
open={!!confirmDeleteNote}
variant="danger"
title="Delete Note"
message="This note will be permanently deleted."
confirmLabel="Delete"
onConfirm={handleDeleteNote}
onClose={() => setConfirmDeleteNote(null)}
loading={deletingNote === confirmDeleteNote?.id}
/>
{/* Confirm: delete issue */}
<ConfirmDialog
open={!!confirmDeleteIssue}
variant="danger"
title="Delete Issue"
message="This issue and all its entity links will be permanently removed."
confirmLabel="Delete"
onConfirm={handleDeleteIssue}
onClose={() => setConfirmDeleteIssue(null)}
loading={deletingIssue}
/>
</>
)
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,857 @@
// frontend/src/pages/bellcloud/devices/tabs/WarrantyTab.jsx
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import { fmtDateMedium, fmtDateLong } from '@/lib/formatters'
// ─── Helpers ──────────────────────────────────────────────────────────────────
function parseDate(val) {
if (!val) return null
// Firestore Timestamp object
if (typeof val === 'object' && typeof val.seconds === 'number') {
return new Date(val.seconds * 1000)
}
const d = new Date(val)
return isNaN(d.getTime()) ? null : d
}
function addDays(date, days) {
if (!date || !days) return null
const d = new Date(date)
d.setDate(d.getDate() + Number(days))
return d
}
function daysUntil(date) {
if (!date) return null
return Math.ceil((date.getTime() - Date.now()) / 86400000)
}
/** 0–100 how much of the period has ELAPSED */
function calcProgress(start, end) {
if (!start || !end) return null
const total = end.getTime() - start.getTime()
if (total <= 0) return 100
const elapsed = Date.now() - start.getTime()
return Math.min(100, Math.max(0, Math.round((elapsed / total) * 100)))
}
/** Remaining portion (inverse of elapsed) */
function calcRemaining(start, end) {
const p = calcProgress(start, end)
return p === null ? null : 100 - p
}
function daysLabel(days) {
if (days == null || days === 0) return '—'
if (days % 365 === 0) {
const y = days / 365
return `${y} year${y !== 1 ? 's' : ''}`
}
if (days % 30 === 0) {
const m = days / 30
return `${m} month${m !== 1 ? 's' : ''}`
}
return `${days} day${days !== 1 ? 's' : ''}`
}
// ─── Glass surface styles ─────────────────────────────────────────────────────
const GLASS = {
background: 'rgba(28, 32, 38, 0.30)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-card), var(--shadow-md)',
borderRadius: 'var(--radius-xl)',
}
const GLASS_ELEVATED = {
background: 'rgba(28, 32, 38, 0.40)',
backdropFilter: 'var(--blur-modal)',
WebkitBackdropFilter: 'var(--blur-modal)',
border: '1px solid var(--color-border)',
boxShadow: 'var(--shadow-sm)',
borderRadius: 'var(--radius-lg)',
}
// ─── Primitives ───────────────────────────────────────────────────────────────
function SectionLabel({ children }) {
return (
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-wide)',
textTransform: 'uppercase',
color: 'var(--color-text-muted)',
}}>
{children}
</span>
)
}
function InfoCell({ label, children }) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<SectionLabel>{label}</SectionLabel>
<span style={{
fontSize: 'var(--font-size-base)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--color-text-primary)',
marginTop: 'var(--space-1)',
}}>
{children ?? '—'}
</span>
</div>
)
}
function ProgressBar({ value, color = 'var(--color-primary)', label, showPct = true }) {
const pct = Math.max(0, Math.min(100, value ?? 0))
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{(label || showPct) && (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
{label && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{label}
</span>
)}
{showPct && (
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-secondary)',
}}>
{pct}%
</span>
)}
</div>
)}
<div style={{
height: 6,
borderRadius: 'var(--radius-full)',
backgroundColor: 'var(--color-bg-island)',
overflow: 'hidden',
}}>
<div style={{
height: '100%',
width: `${pct}%`,
backgroundColor: color,
borderRadius: 'var(--radius-full)',
transition: 'width 0.6s ease',
boxShadow: `0 0 8px ${color}60`,
}} />
</div>
</div>
)
}
function Divider() {
return <div style={{ height: 1, backgroundColor: 'var(--color-border)', margin: 'var(--space-1) 0' }} />
}
function DetailRow({ label, value, valueColor }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 'var(--space-4)', minHeight: 28 }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', flexShrink: 0 }}>
{label}
</span>
<span style={{
fontSize: 'var(--font-size-sm)',
fontWeight: 'var(--font-weight-medium)',
color: valueColor ?? (!value || value === '—' ? 'var(--color-text-muted)' : 'var(--color-text-primary)'),
textAlign: 'right',
}}>
{value ?? '—'}
</span>
</div>
)
}
// ─── Subscription Hero ────────────────────────────────────────────────────────
function SubscriptionHero({ sub, canEdit, onEditSubscription, deviceUsers }) {
const subscrStart = parseDate(sub?.subscrStart)
const subscrEnd = addDays(subscrStart, sub?.subscrDuration)
const daysLeft = daysUntil(subscrEnd)
const remaining = calcRemaining(subscrStart, subscrEnd)
const isActive = daysLeft === null || daysLeft > 0
const tier = sub?.subscrTier || null
const progressColor = daysLeft === null
? 'var(--color-primary)'
: daysLeft > 60
? 'var(--color-success)'
: daysLeft > 14
? 'var(--color-warning)'
: 'var(--color-danger)'
// Registered users = actual device users count passed from parent
const regUsers = Array.isArray(deviceUsers) ? deviceUsers.length : null
const maxUsers = sub?.maxUsers ?? null
const hwOutputs = sub?.hw_outputs ?? null
const maxOutputs = sub?.maxOutputs ?? null
return (
<div style={{
...GLASS,
padding: 'var(--space-6) var(--space-8)',
position: 'relative',
overflow: 'hidden',
borderLeft: '3px solid var(--color-primary)',
}}>
{/* Left accent glow */}
<div aria-hidden="true" style={{
position: 'absolute',
left: 0, top: 0, bottom: 0,
width: 140,
pointerEvents: 'none',
background: 'linear-gradient(90deg, rgba(192,193,255,0.06) 0%, transparent 100%)',
}} />
{/* Right ambient glow */}
<div aria-hidden="true" style={{
position: 'absolute', inset: 0, pointerEvents: 'none',
background: 'radial-gradient(ellipse 60% 120% at 90% 50%, rgba(192,193,255,0.03) 0%, transparent 70%)',
}} />
<div style={{ position: 'relative', display: 'flex', alignItems: 'center', gap: 'var(--space-6)', flexWrap: 'wrap' }}>
{/* ── Column 1: Tier ── */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)', minWidth: 180, flexShrink: 0 }}>
<div style={{
width: 48, height: 48,
borderRadius: 'var(--radius-lg)',
backgroundColor: 'var(--color-primary-subtle)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</div>
<div>
<div style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-lg)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
textTransform: 'capitalize',
}}>
{tier ? `${tier} Tier` : 'No Subscription'}
</div>
<div style={{ marginTop: 4 }}>
<StatusBadge variant={isActive ? 'success' : 'danger'}>
{isActive ? 'Active License' : 'Expired'}
</StatusBadge>
</div>
</div>
</div>
{/* ── Column 2: Progress bar ── */}
<div style={{
flex: 1, minWidth: 220,
paddingLeft: 'var(--space-6)',
borderLeft: '1px solid var(--color-border)',
display: 'flex', flexDirection: 'column', gap: 'var(--space-3)',
}}>
{/* Top label row */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline' }}>
<span style={{
fontSize: 'var(--font-size-sm)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--color-text-primary)',
}}>
{daysLeft === null
? 'No expiry set'
: daysLeft <= 0
? 'Subscription expired'
: `${daysLeft} days remaining`}
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{daysLabel(sub?.subscrDuration)} plan
</span>
</div>
{/* Progress bar */}
<ProgressBar value={remaining ?? 0} color={progressColor} showPct={false} />
{/* Bottom: inline Start / Renewal */}
<div style={{ display: 'flex', gap: 'var(--space-8)', alignItems: 'center' }}>
{subscrStart && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Start:{' '}
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
{fmtDateMedium(subscrStart.toISOString())}
</span>
</span>
)}
{subscrEnd && (
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Renewal:{' '}
<span style={{ color: 'var(--color-text-secondary)', fontWeight: 'var(--font-weight-medium)' }}>
{fmtDateMedium(subscrEnd.toISOString())}
</span>
</span>
)}
</div>
</div>
{/* ── Column 3: Users + Outputs + Edit — side by side ── */}
<div style={{
display: 'flex', flexDirection: 'row', alignItems: 'center', gap: 'var(--space-6)',
paddingLeft: 'var(--space-6)',
borderLeft: '1px solid var(--color-border)',
flexShrink: 0,
}}>
{/* Users block */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
<div style={{
width: 48, height: 48,
borderRadius: 'var(--radius-lg)',
backgroundColor: 'var(--color-primary-subtle)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-secondary)',
}}>
Device Users
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Max:{' '}
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
{maxUsers ?? '—'}
</span>
{regUsers !== null && (
<>
{' '}<span style={{ color: 'var(--color-border-strong)' }}>|</span>{' '}
Reg:{' '}
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
{regUsers}
</span>
</>
)}
</span>
</div>
</div>
{/* Outputs block */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
<div style={{
width: 48, height: 48,
borderRadius: 'var(--radius-lg)',
backgroundColor: 'var(--color-primary-subtle)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="var(--color-primary)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3" />
<path d="M19.07 4.93a10 10 0 010 14.14M4.93 4.93a10 10 0 000 14.14" />
</svg>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<span style={{
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-semibold)',
color: 'var(--color-text-secondary)',
}}>
Outputs
</span>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
HW:{' '}
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
{hwOutputs ?? '—'}
</span>
{maxOutputs !== null && (
<>
{' '}<span style={{ color: 'var(--color-border-strong)' }}>|</span>{' '}
Max:{' '}
<span style={{ color: 'var(--color-text-primary)', fontWeight: 'var(--font-weight-medium)' }}>
{maxOutputs}
</span>
</>
)}
</span>
</div>
</div>
{/* Edit pencil icon button */}
{canEdit && (
<button
type="button"
onClick={onEditSubscription}
aria-label="Edit subscription plan"
title="Edit subscription plan"
style={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
width: 40,
height: 40,
borderRadius: 'var(--radius-md)',
border: 'none',
background: 'transparent',
color: 'var(--color-text-muted)',
cursor: 'pointer',
transition: 'background 0.15s, color 0.15s',
flexShrink: 0,
}}
onMouseEnter={e => {
e.currentTarget.style.background = 'rgba(49,53,60,0.50)'
e.currentTarget.style.color = 'var(--color-text-primary)'
}}
onMouseLeave={e => {
e.currentTarget.style.background = 'transparent'
e.currentTarget.style.color = 'var(--color-text-muted)'
}}
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
)}
</div>
</div>
</div>
)
}
// ─── Warranty Card ────────────────────────────────────────────────────────────
function WarrantyCard({ stats, canEdit, onEditWarranty }) {
const warrantyStart = parseDate(stats?.warrantyStart)
const warrantyEnd = addDays(warrantyStart, stats?.warrantyPeriod)
const daysLeft = daysUntil(warrantyEnd)
const remaining = calcRemaining(warrantyStart, warrantyEnd)
const isVoid = stats?.warrantyActive === false
const isExpired = !isVoid && daysLeft !== null && daysLeft <= 0
const isActive = !isVoid && !isExpired && (daysLeft === null || daysLeft > 0)
const statusLabel = isVoid ? 'Warranty Void' : isExpired ? 'Expired' : isActive ? 'Active' : '—'
const statusVariant = isVoid ? 'warning' : isExpired ? 'danger' : isActive ? 'success' : 'neutral'
const borderAccent = isVoid
? 'var(--color-warning)'
: isExpired
? 'var(--color-danger)'
: 'var(--color-success)'
const progressColor = isVoid || isExpired ? 'var(--color-danger)' : 'var(--color-success)'
return (
<div style={{
...GLASS,
padding: 'var(--space-6)',
borderLeft: `3px solid ${borderAccent}`,
position: 'relative',
overflow: 'hidden',
}}>
{/* Accent side glow */}
<div aria-hidden="true" style={{
position: 'absolute',
left: 0, top: 0, bottom: 0,
width: 140,
pointerEvents: 'none',
background: `linear-gradient(90deg, ${borderAccent}12 0%, transparent 100%)`,
}} />
<div style={{ position: 'relative', display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-4)' }}>
<div style={{
width: 44, height: 44,
borderRadius: 'var(--radius-full)',
backgroundColor: `${borderAccent}18`,
display: 'flex', alignItems: 'center', justifyContent: 'center',
flexShrink: 0,
}}>
{isActive ? (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={borderAccent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
<polyline points="9 12 11 14 15 10" />
</svg>
) : (
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke={borderAccent} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
)}
</div>
<div>
<div style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-lg)',
fontWeight: 'var(--font-weight-bold)',
letterSpacing: 'var(--tracking-tight)',
color: borderAccent,
}}>
{statusLabel}
</div>
<div style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', marginTop: 3 }}>
{isVoid
? 'Warranty has been manually voided'
: isExpired
? warrantyEnd
? `Coverage ended on ${fmtDateLong(warrantyEnd.toISOString())}`
: 'Coverage has expired'
: warrantyEnd
? `Active until ${fmtDateMedium(warrantyEnd.toISOString())}`
: 'Warranty is currently active'}
</div>
</div>
</div>
{canEdit && (
<Button variant="ghost" size="sm" onClick={onEditWarranty}>
Edit Warranty
</Button>
)}
</div>
{/* Progress */}
<ProgressBar
value={remaining ?? 0}
color={progressColor}
label={
daysLeft === null
? 'Coverage period'
: daysLeft <= 0
? 'Coverage elapsed'
: `${daysLeft} day${daysLeft !== 1 ? 's' : ''} remaining`
}
/>
{/* Detail grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(130px, 1fr))',
gap: 'var(--space-4) var(--space-6)',
}}>
<InfoCell label="Start Date">
{warrantyStart ? fmtDateMedium(warrantyStart.toISOString()) : '—'}
</InfoCell>
<InfoCell label="Expiry Date">
{warrantyEnd ? fmtDateMedium(warrantyEnd.toISOString()) : '—'}
</InfoCell>
<InfoCell label="Coverage Period">
{daysLabel(stats?.warrantyPeriod)}
</InfoCell>
<InfoCell label="Status">
<StatusBadge variant={statusVariant}>{statusLabel}</StatusBadge>
</InfoCell>
</div>
</div>
</div>
)
}
// ─── Maintenance Card ─────────────────────────────────────────────────────────
function MaintenanceCard({ stats }) {
const maintainedOn = parseDate(stats?.maintainedOn)
const nextMaint = addDays(maintainedOn, stats?.maintainancePeriod)
const daysLeft = daysUntil(nextMaint)
const remaining = calcRemaining(maintainedOn, nextMaint)
const isOverdue = daysLeft !== null && daysLeft <= 0
const isDueSoon = daysLeft !== null && daysLeft > 0 && daysLeft <= 30
const statusVariant = isOverdue ? 'danger' : isDueSoon ? 'warning' : 'neutral'
const progressColor = isOverdue
? 'var(--color-danger)'
: isDueSoon
? 'var(--color-warning)'
: 'var(--color-info)'
return (
<div style={{ ...GLASS, padding: 'var(--space-6)', display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
{/* Title */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)', flexWrap: 'wrap' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
</svg>
<span style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-md)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
}}>
Maintenance Schedule
</span>
{(isOverdue || isDueSoon) && (
<StatusBadge variant={statusVariant}>
{isOverdue ? 'Overdue' : 'Due Soon'}
</StatusBadge>
)}
</div>
{/* Next service highlight */}
{nextMaint ? (
<div style={{
...GLASS_ELEVATED,
padding: 'var(--space-4)',
display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 'var(--space-4)', flexWrap: 'wrap',
}}>
<div>
<SectionLabel>Next Scheduled Service</SectionLabel>
<div style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-xl)',
fontWeight: 'var(--font-weight-bold)',
letterSpacing: 'var(--tracking-tight)',
color: isOverdue ? 'var(--color-danger)' : isDueSoon ? 'var(--color-warning)' : 'var(--color-text-primary)',
marginTop: 'var(--space-1)',
}}>
{fmtDateLong(nextMaint.toISOString())}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<SectionLabel>{isOverdue ? 'Overdue by' : 'In'}</SectionLabel>
<div style={{
fontSize: 'var(--font-size-lg)',
fontWeight: 'var(--font-weight-bold)',
color: isOverdue ? 'var(--color-danger)' : isDueSoon ? 'var(--color-warning)' : 'var(--color-text-secondary)',
marginTop: 'var(--space-1)',
}}>
{daysLeft != null ? `${Math.abs(daysLeft)} day${Math.abs(daysLeft) !== 1 ? 's' : ''}` : '—'}
</div>
</div>
</div>
) : (
<div style={{
...GLASS_ELEVATED,
padding: 'var(--space-4)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
}}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontStyle: 'italic' }}>
No maintenance schedule set
</span>
</div>
)}
{/* Progress bar */}
<ProgressBar
value={remaining ?? 0}
color={progressColor}
label="Progress to next service"
/>
{/* Detail rows */}
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<DetailRow
label="Last Maintained"
value={maintainedOn ? fmtDateMedium(maintainedOn.toISOString()) : null}
/>
<Divider />
<DetailRow
label="Service Interval"
value={daysLabel(stats?.maintainancePeriod)}
/>
<Divider />
<DetailRow
label="Next Service"
value={nextMaint ? fmtDateMedium(nextMaint.toISOString()) : null}
valueColor={isOverdue ? 'var(--color-danger)' : isDueSoon ? 'var(--color-warning)' : undefined}
/>
</div>
</div>
)
}
// ─── Performance Metrics ──────────────────────────────────────────────────────
function MetricTile({ icon, label, value, accent = 'var(--color-primary)', tag, tagVariant = 'info' }) {
return (
<div style={{
...GLASS_ELEVATED,
padding: 'var(--space-5)',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 'var(--space-4)',
minHeight: 100,
}}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div style={{ color: accent, display: 'flex', alignItems: 'center' }}>
{icon}
</div>
{tag != null && (
<StatusBadge variant={tagVariant}>{tag}</StatusBadge>
)}
</div>
<div>
<div style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-2xl)',
fontWeight: 'var(--font-weight-bold)',
letterSpacing: 'var(--tracking-display)',
color: 'var(--color-text-primary)',
lineHeight: 1,
}}>
{value}
</div>
<div style={{
fontSize: 'var(--font-size-xs)',
color: 'var(--color-text-muted)',
fontWeight: 'var(--font-weight-medium)',
marginTop: 'var(--space-1)',
}}>
{label}
</div>
</div>
</div>
)
}
function PerformanceMetrics({ device, stats }) {
const totalWarnings = Number.isFinite(stats?.totalWarningsGiven) ? stats.totalWarningsGiven : null
const melodiesCount = Array.isArray(device?.device_melodies_all) ? device.device_melodies_all.length : null
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-5)' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="var(--color-text-muted)" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
</svg>
<span style={{
fontFamily: 'var(--font-family-display)',
fontSize: 'var(--font-size-md)',
fontWeight: 'var(--font-weight-semibold)',
letterSpacing: 'var(--tracking-tight)',
color: 'var(--color-text-primary)',
}}>
Device Performance
</span>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-3)' }}>
{/* System Warnings */}
<MetricTile
accent={totalWarnings !== null && totalWarnings > 0 ? 'var(--color-danger)' : 'var(--color-success)'}
tag={totalWarnings !== null ? (totalWarnings === 0 ? 'Clean' : 'Flagged') : null}
tagVariant={totalWarnings === 0 ? 'success' : 'danger'}
value={totalWarnings !== null ? totalWarnings : '—'}
label="System Warnings"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
<line x1="12" y1="9" x2="12" y2="13" />
<line x1="12" y1="17" x2="12.01" y2="17" />
</svg>
}
/>
{/* On-board Melodies */}
<MetricTile
accent="var(--color-info)"
tag={melodiesCount !== null ? 'Loaded' : null}
tagVariant="info"
value={melodiesCount !== null ? melodiesCount : '—'}
label="On-board Melodies"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
}
/>
{/* Firmware Version */}
<MetricTile
accent="var(--color-primary)"
tag={device?.device_firmware_version ? 'Current' : null}
tagVariant="neutral"
value={device?.device_firmware_version ?? '—'}
label="Firmware Version"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<rect x="4" y="4" width="16" height="16" rx="2" />
<rect x="9" y="9" width="6" height="6" />
<path d="M15 2v2M9 2v2M15 20v2M9 20v2M20 9h2M20 14h2M2 9h2M2 14h2" />
</svg>
}
/>
{/* Total Melodies (favorites) */}
<MetricTile
accent="var(--color-warning)"
tag={Array.isArray(device?.device_melodies_favorites) && device.device_melodies_favorites.length > 0 ? 'Saved' : null}
tagVariant="warning"
value={Array.isArray(device?.device_melodies_favorites) ? device.device_melodies_favorites.length : '—'}
label="Favourite Melodies"
icon={
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M20.84 4.61a5.5 5.5 0 00-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 00-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 000-7.78z" />
</svg>
}
/>
</div>
</div>
)
}
// ─── WarrantyTab ──────────────────────────────────────────────────────────────
export default function WarrantyTab({
device,
canEdit,
sub,
stats,
deviceUsers,
onEditSubscription,
onEditWarranty,
}) {
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-6)', width: '100%', maxWidth: 2000, margin: '0 auto' }}>
{/* Subscription hero — no section label above */}
<SubscriptionHero
sub={sub}
canEdit={canEdit}
onEditSubscription={onEditSubscription}
deviceUsers={deviceUsers}
/>
{/* Warranty card — no section label above */}
<WarrantyCard stats={stats} canEdit={canEdit} onEditWarranty={onEditWarranty} />
{/* Masonry: Maintenance + Metrics side by side */}
<div className="masonry-grid masonry-grid--2">
<MaintenanceCard stats={stats} />
<div style={{ ...GLASS, padding: 'var(--space-6)' }}>
<PerformanceMetrics device={device} stats={stats} />
</div>
</div>
</div>
)
}
@@ -0,0 +1,171 @@
// frontend/src/pages/bellcloud/devices/tabs/shared.jsx
// Shared helpers, sub-components, and constants used across device tabs
import Button from '@/components/ui/Button'
import Card from '@/components/ui/Card'
// ─── Date / time helpers ──────────────────────────────────────────────────────
export function parseFirestoreDate(str) {
if (!str) return null
if (typeof str === 'object') {
const secs = str._seconds ?? str.seconds
if (typeof secs === 'number') return new Date(secs * 1000)
return null
}
const cleaned = str.replace(' at ', ' ').replace('UTC+0000', 'UTC').replace(/UTC\+(\d{4})/, 'UTC')
const d = new Date(cleaned)
return isNaN(d.getTime()) ? null : d
}
export function formatDate(str) {
const d = parseFirestoreDate(str)
if (!d) return str || '—'
const day = d.getUTCDate().toString().padStart(2, '0')
const month = (d.getUTCMonth() + 1).toString().padStart(2, '0')
return `${day}-${month}-${d.getUTCFullYear()}`
}
export function formatDateNice(d) {
if (!d) return '—'
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
return `${d.getUTCDate()} ${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`
}
export function daysUntil(targetDate) {
return Math.ceil((targetDate.getTime() - Date.now()) / 86400000)
}
export function addDays(date, days) {
return new Date(date.getTime() + days * 86400000)
}
export function daysToDisplay(days) {
if (days >= 365 && days % 365 === 0) return `${days / 365} year${days / 365 > 1 ? 's' : ''} (${days} days)`
if (days >= 30) {
const months = Math.floor(days / 30)
const rem = days % 30
if (rem === 0) return `${months} month${months > 1 ? 's' : ''} (${days} days)`
return `${days} days (~${months} month${months > 1 ? 's' : ''})`
}
return `${days} days`
}
export function formatTimestamp(str) {
if (!str) return '—'
const d = parseFirestoreDate(str)
if (d) return `${d.getUTCHours().toString().padStart(2, '0')}:${d.getUTCMinutes().toString().padStart(2, '0')}`
const match = str.match(/(\d{1,2}):(\d{2})/)
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : str
}
export function parseCoordinates(coordStr) {
if (!coordStr) return null
if (typeof coordStr === 'object') {
const lat = parseFloat(coordStr.lat ?? coordStr.latitude)
const lng = parseFloat(coordStr.lng ?? coordStr.longitude)
if (!isNaN(lat) && !isNaN(lng)) return { lat, lng }
return null
}
const numbers = coordStr.match(/-?\d+(?:\.\d+)?/g)
if (numbers?.length >= 2) {
let lat = parseFloat(numbers[0])
let lng = parseFloat(numbers[1])
if (!isNaN(lat) && !isNaN(lng)) {
if (/\bS\b/.test(coordStr)) lat = -Math.abs(lat)
if (/\bW\b/.test(coordStr)) lng = -Math.abs(lng)
return { lat, lng }
}
}
return null
}
export function formatCoordinates(coords) {
if (!coords) return '—'
const latDir = coords.lat >= 0 ? 'N' : 'S'
const lngDir = coords.lng >= 0 ? 'E' : 'W'
return `${Math.abs(coords.lat).toFixed(6)}° ${latDir}, ${Math.abs(coords.lng).toFixed(6)}° ${lngDir}`
}
export function formatLocale(v) {
if (!v || v === 'all') return 'Global'
if (v === 'orthodox') return 'Orthodox'
if (v === 'catholic') return 'Catholic'
return v
}
// ─── Constants ────────────────────────────────────────────────────────────────
export const LOG_LEVEL_LABELS = { 0: 'Disabled', 1: 'Error', 2: 'Warning', 3: 'Info', 4: 'Debug', 5: 'Verbose' }
export const LOG_LEVEL_VARIANTS = { 0: 'neutral', 1: 'danger', 2: 'warning', 3: 'info', 4: 'neutral', 5: 'neutral' }
// ─── Shared sub-components ────────────────────────────────────────────────────
export function Field({ label, children, mono = false }) {
const isEmpty = children == null || children === ''
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<dt style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', fontWeight: 'var(--font-weight-medium)' }}>
{label}
</dt>
<dd style={{
fontSize: 'var(--font-size-base)',
color: isEmpty ? 'var(--color-text-muted)' : 'var(--color-text-primary)',
fontFamily: mono ? 'var(--font-family-mono)' : 'inherit',
}}>
{isEmpty ? '—' : children}
</dd>
</div>
)
}
export function FieldGrid({ children, cols = 2 }) {
return (
<dl style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))`, gap: 'var(--space-4)' }}>
{children}
</dl>
)
}
export function SectionTitle({ title, onEdit, canEdit }) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<h3 style={{ fontSize: 'var(--font-size-base)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{title}
</h3>
{canEdit && onEdit && (
<Button variant="ghost" size="sm" onClick={onEdit}>Edit</Button>
)}
</div>
)
}
export function KpiCard({ label, value, variant }) {
const valueColor = variant === 'success'
? 'var(--color-success)'
: variant === 'danger'
? 'var(--color-danger)'
: variant === 'warning'
? 'var(--color-warning)'
: 'var(--color-text-primary)'
return (
<Card padding>
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-1)' }}>
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>{label}</span>
<span style={{ fontSize: 'var(--font-size-2xl)', fontWeight: 'var(--font-weight-bold)', letterSpacing: 'var(--tracking-display)', color: valueColor, lineHeight: 1, fontFamily: 'var(--font-family-display)' }}>
{value}
</span>
</div>
</Card>
)
}
export function ProgressBar({ value, variant = 'primary' }) {
const width = Number.isFinite(value) ? Math.max(0, Math.min(100, value)) : 0
const color = variant === 'success' ? 'var(--color-success)' : variant === 'danger' ? 'var(--color-danger)' : variant === 'warning' ? 'var(--color-warning)' : 'var(--color-primary)'
return (
<div style={{ height: '6px', borderRadius: 'var(--radius-full)', backgroundColor: 'var(--color-bg-elevated)', overflow: 'hidden' }}>
<div style={{ height: '100%', width: `${width}%`, backgroundColor: color, borderRadius: 'var(--radius-full)', transition: 'width 0.3s' }} />
</div>
)
}