Admin Panel (Next.js): - Dashboard with stats - Users list with relationships (watches/watched_by) - User detail pages - Deployments list and detail pages - Devices, Orders, Subscriptions pages - OTP-based admin authentication Backend Optimizations: - Fixed N+1 query problem in admin APIs - Added pagination support - Added .range() and count support to Supabase wrapper - Optimized batch queries with lookup maps Database: - Added migrations for schema evolution - New tables: push_tokens, notification_settings - Updated access model iOS Build Scripts: - build-ios.sh, clear-apple-cache.sh - EAS configuration updates 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
513 lines
13 KiB
JavaScript
513 lines
13 KiB
JavaScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useParams, useRouter } from 'next/navigation';
|
|
import AdminLayout from '../../../components/AdminLayout';
|
|
import { getDeployment } from '../../../lib/api';
|
|
|
|
export default function DeploymentDetailPage() {
|
|
const { id } = useParams();
|
|
const router = useRouter();
|
|
const [deployment, setDeployment] = useState(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
|
|
useEffect(() => {
|
|
if (id) loadDeployment();
|
|
}, [id]);
|
|
|
|
const loadDeployment = async () => {
|
|
try {
|
|
const data = await getDeployment(id);
|
|
setDeployment(data);
|
|
} catch (err) {
|
|
setError(err.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
if (loading) {
|
|
return (
|
|
<AdminLayout>
|
|
<p style={styles.loading}>Loading...</p>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
if (error || !deployment) {
|
|
return (
|
|
<AdminLayout>
|
|
<div style={styles.error}>
|
|
<p>Deployment not found</p>
|
|
<button onClick={() => router.push('/deployments')} style={styles.backBtn}>
|
|
Back to Deployments
|
|
</button>
|
|
</div>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
const ownerName = deployment.owner?.first_name || deployment.owner?.last_name
|
|
? `${deployment.owner.first_name || ''} ${deployment.owner.last_name || ''}`.trim()
|
|
: deployment.owner?.email || 'No owner';
|
|
|
|
return (
|
|
<AdminLayout>
|
|
{/* Header */}
|
|
<div style={styles.header}>
|
|
<button onClick={() => router.push('/deployments')} style={styles.backLink}>
|
|
← Back to Deployments
|
|
</button>
|
|
<div style={styles.titleRow}>
|
|
<h1 style={styles.title}>Deployment #{deployment.deployment_id}</h1>
|
|
<StatusBadge status={deployment.status} />
|
|
</div>
|
|
{deployment.address && (
|
|
<p style={styles.address}>{deployment.address}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Info Cards */}
|
|
<div style={styles.grid}>
|
|
{/* Deployment Info */}
|
|
<div style={styles.card}>
|
|
<h3 style={styles.cardTitle}>Deployment Information</h3>
|
|
<InfoRow label="ID" value={`#${deployment.deployment_id}`} />
|
|
<InfoRow label="Address" value={deployment.address || '—'} />
|
|
<InfoRow label="Status" value={deployment.status || '—'} />
|
|
<InfoRow label="Created" value={deployment.created_at ? new Date(deployment.created_at).toLocaleDateString() : '—'} />
|
|
</div>
|
|
|
|
{/* Owner Info */}
|
|
<div style={styles.card}>
|
|
<h3 style={styles.cardTitle}>Owner</h3>
|
|
{deployment.owner ? (
|
|
<>
|
|
<InfoRow label="Name" value={ownerName} />
|
|
<InfoRow label="Email" value={deployment.owner.email} />
|
|
<InfoRow label="Phone" value={deployment.owner.phone || '—'} />
|
|
<div style={{ marginTop: '16px' }}>
|
|
<button
|
|
onClick={() => router.push(`/users/${deployment.owner.id}`)}
|
|
style={styles.viewBtn}
|
|
>
|
|
View User Profile
|
|
</button>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<p style={styles.muted}>No owner assigned</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div style={styles.statsRow}>
|
|
<StatCard
|
|
icon="📡"
|
|
label="Devices"
|
|
value={deployment.devices?.length || 0}
|
|
color="#3B82F6"
|
|
/>
|
|
<StatCard
|
|
icon="👥"
|
|
label="Users with Access"
|
|
value={deployment.users_with_access?.length || 0}
|
|
color="#10B981"
|
|
/>
|
|
<StatCard
|
|
icon="📊"
|
|
label="Events"
|
|
value={deployment.events?.length || 0}
|
|
color="#8B5CF6"
|
|
/>
|
|
</div>
|
|
|
|
{/* Devices */}
|
|
{deployment.devices?.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>Devices ({deployment.devices.length})</h2>
|
|
<div style={styles.deviceGrid}>
|
|
{deployment.devices.map((device) => (
|
|
<DeviceCard key={device.device_id} device={device} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Users with Access */}
|
|
{deployment.users_with_access?.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>Users with Access</h2>
|
|
<div style={styles.table}>
|
|
<div style={styles.tableHeader}>
|
|
<span>User</span>
|
|
<span>Email</span>
|
|
<span>Role</span>
|
|
<span>Granted</span>
|
|
</div>
|
|
{deployment.users_with_access.map((access, idx) => (
|
|
<div key={idx} style={styles.tableRow}>
|
|
<span style={styles.userName}>
|
|
{access.user?.first_name || access.user?.last_name
|
|
? `${access.user.first_name || ''} ${access.user.last_name || ''}`.trim()
|
|
: 'Unnamed'}
|
|
</span>
|
|
<span style={styles.email}>{access.user?.email || '—'}</span>
|
|
<span>
|
|
<RoleBadge role={access.role} />
|
|
</span>
|
|
<span style={styles.muted}>
|
|
{access.granted_at ? new Date(access.granted_at).toLocaleDateString() : '—'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Events */}
|
|
{deployment.events?.length > 0 && (
|
|
<div style={styles.section}>
|
|
<h2 style={styles.sectionTitle}>Recent Events</h2>
|
|
<div style={styles.table}>
|
|
<div style={styles.tableHeader}>
|
|
<span>Type</span>
|
|
<span>Description</span>
|
|
<span>Device</span>
|
|
<span>Time</span>
|
|
</div>
|
|
{deployment.events.map((event, idx) => (
|
|
<div key={idx} style={styles.tableRow}>
|
|
<span>
|
|
<EventTypeBadge type={event.event_type} />
|
|
</span>
|
|
<span>{event.description || '—'}</span>
|
|
<span style={styles.mono}>{event.device_id || '—'}</span>
|
|
<span style={styles.muted}>
|
|
{new Date(event.created_at).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function InfoRow({ label, value }) {
|
|
return (
|
|
<div style={styles.infoRow}>
|
|
<span style={styles.infoLabel}>{label}</span>
|
|
<span style={styles.infoValue}>{value}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatCard({ icon, label, value, color }) {
|
|
return (
|
|
<div style={styles.statCard}>
|
|
<div style={styles.statIcon}>{icon}</div>
|
|
<div>
|
|
<div style={{ ...styles.statValue, color }}>{value}</div>
|
|
<div style={styles.statLabel}>{label}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DeviceCard({ device }) {
|
|
const isOnline = device.status === 'online' || device.status === 'active';
|
|
|
|
return (
|
|
<div style={styles.deviceCard}>
|
|
<div style={styles.deviceHeader}>
|
|
<span style={styles.deviceId}>{device.device_id}</span>
|
|
<span style={{
|
|
...styles.statusDot,
|
|
background: isOnline ? '#10B981' : '#EF4444'
|
|
}} />
|
|
</div>
|
|
<div style={styles.deviceType}>{device.device_type || 'Unknown Type'}</div>
|
|
<div style={styles.deviceMeta}>
|
|
<span>Status: {device.status || 'unknown'}</span>
|
|
{device.last_seen_at && (
|
|
<span>Last seen: {new Date(device.last_seen_at).toLocaleString()}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }) {
|
|
const colors = {
|
|
active: '#10B981',
|
|
installed: '#059669',
|
|
pending: '#F59E0B',
|
|
inactive: '#6B7280',
|
|
};
|
|
const color = colors[status] || '#6B7280';
|
|
|
|
return (
|
|
<span style={{
|
|
padding: '6px 14px',
|
|
borderRadius: '12px',
|
|
fontSize: '13px',
|
|
fontWeight: '600',
|
|
background: `${color}15`,
|
|
color: color,
|
|
textTransform: 'capitalize',
|
|
}}>
|
|
{status || 'unknown'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function RoleBadge({ role }) {
|
|
const colors = {
|
|
owner: '#EF4444',
|
|
caretaker: '#6366F1',
|
|
installer: '#8B5CF6',
|
|
};
|
|
const color = colors[role] || '#6B7280';
|
|
|
|
return (
|
|
<span style={{
|
|
padding: '3px 10px',
|
|
borderRadius: '8px',
|
|
fontSize: '11px',
|
|
fontWeight: '500',
|
|
background: `${color}15`,
|
|
color: color,
|
|
textTransform: 'capitalize',
|
|
}}>
|
|
{role}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function EventTypeBadge({ type }) {
|
|
const colors = {
|
|
alert: '#EF4444',
|
|
motion: '#F59E0B',
|
|
door: '#3B82F6',
|
|
system: '#6B7280',
|
|
};
|
|
const color = colors[type] || '#6B7280';
|
|
|
|
return (
|
|
<span style={{
|
|
padding: '3px 10px',
|
|
borderRadius: '8px',
|
|
fontSize: '11px',
|
|
fontWeight: '500',
|
|
background: `${color}15`,
|
|
color: color,
|
|
textTransform: 'capitalize',
|
|
}}>
|
|
{type || 'event'}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const styles = {
|
|
loading: {
|
|
textAlign: 'center',
|
|
padding: '48px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
error: {
|
|
textAlign: 'center',
|
|
padding: '48px',
|
|
},
|
|
backBtn: {
|
|
marginTop: '16px',
|
|
padding: '10px 20px',
|
|
background: 'var(--primary)',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
cursor: 'pointer',
|
|
},
|
|
header: {
|
|
marginBottom: '24px',
|
|
},
|
|
backLink: {
|
|
background: 'none',
|
|
border: 'none',
|
|
color: 'var(--primary)',
|
|
fontSize: '14px',
|
|
cursor: 'pointer',
|
|
padding: 0,
|
|
marginBottom: '12px',
|
|
display: 'block',
|
|
},
|
|
titleRow: {
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
flexWrap: 'wrap',
|
|
},
|
|
title: {
|
|
fontSize: '28px',
|
|
fontWeight: '700',
|
|
margin: 0,
|
|
},
|
|
address: {
|
|
color: 'var(--text-secondary)',
|
|
marginTop: '8px',
|
|
fontSize: '15px',
|
|
},
|
|
grid: {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))',
|
|
gap: '20px',
|
|
},
|
|
card: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
},
|
|
cardTitle: {
|
|
fontSize: '14px',
|
|
fontWeight: '600',
|
|
marginBottom: '16px',
|
|
color: 'var(--text-secondary)',
|
|
},
|
|
infoRow: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
padding: '10px 0',
|
|
borderBottom: '1px solid var(--border)',
|
|
},
|
|
infoLabel: {
|
|
color: 'var(--text-muted)',
|
|
fontSize: '14px',
|
|
},
|
|
infoValue: {
|
|
fontWeight: '500',
|
|
fontSize: '14px',
|
|
},
|
|
statsRow: {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
|
gap: '16px',
|
|
marginTop: '24px',
|
|
},
|
|
statCard: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
padding: '20px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '16px',
|
|
},
|
|
statIcon: {
|
|
fontSize: '32px',
|
|
},
|
|
statValue: {
|
|
fontSize: '28px',
|
|
fontWeight: '700',
|
|
},
|
|
statLabel: {
|
|
fontSize: '13px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
section: {
|
|
marginTop: '32px',
|
|
},
|
|
sectionTitle: {
|
|
fontSize: '18px',
|
|
fontWeight: '600',
|
|
marginBottom: '16px',
|
|
},
|
|
deviceGrid: {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))',
|
|
gap: '16px',
|
|
},
|
|
deviceCard: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
padding: '16px',
|
|
},
|
|
deviceHeader: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '8px',
|
|
},
|
|
deviceId: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '14px',
|
|
fontWeight: '600',
|
|
},
|
|
statusDot: {
|
|
width: '10px',
|
|
height: '10px',
|
|
borderRadius: '50%',
|
|
},
|
|
deviceType: {
|
|
fontSize: '15px',
|
|
fontWeight: '500',
|
|
marginBottom: '8px',
|
|
},
|
|
deviceMeta: {
|
|
fontSize: '12px',
|
|
color: 'var(--text-muted)',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
gap: '4px',
|
|
},
|
|
table: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
overflow: 'hidden',
|
|
},
|
|
tableHeader: {
|
|
display: 'grid',
|
|
gridTemplateColumns: '1.5fr 2fr 1fr 1fr',
|
|
gap: '16px',
|
|
padding: '14px 20px',
|
|
background: 'var(--surface)',
|
|
fontSize: '12px',
|
|
fontWeight: '600',
|
|
color: 'var(--text-muted)',
|
|
textTransform: 'uppercase',
|
|
},
|
|
tableRow: {
|
|
display: 'grid',
|
|
gridTemplateColumns: '1.5fr 2fr 1fr 1fr',
|
|
gap: '16px',
|
|
padding: '14px 20px',
|
|
borderBottom: '1px solid var(--border)',
|
|
alignItems: 'center',
|
|
fontSize: '14px',
|
|
},
|
|
userName: {
|
|
fontWeight: '500',
|
|
},
|
|
email: {
|
|
color: 'var(--text-secondary)',
|
|
},
|
|
mono: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '13px',
|
|
},
|
|
muted: {
|
|
color: 'var(--text-muted)',
|
|
fontSize: '13px',
|
|
},
|
|
viewBtn: {
|
|
padding: '8px 16px',
|
|
background: 'var(--primary)',
|
|
color: 'white',
|
|
border: 'none',
|
|
borderRadius: '8px',
|
|
fontSize: '13px',
|
|
cursor: 'pointer',
|
|
},
|
|
};
|