Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user