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>
208 lines
5.1 KiB
JavaScript
208 lines
5.1 KiB
JavaScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import AdminLayout from '../../components/AdminLayout';
|
|
import { getDevices } from '../../lib/api';
|
|
|
|
export default function DevicesPage() {
|
|
const [devices, setDevices] = useState([]);
|
|
const [search, setSearch] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
loadDevices();
|
|
}, []);
|
|
|
|
const loadDevices = async () => {
|
|
try {
|
|
const data = await getDevices();
|
|
setDevices(data.devices || []);
|
|
} catch (err) {
|
|
console.error('Failed to load devices:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredDevices = devices.filter((dev) => {
|
|
if (!search) return true;
|
|
const s = search.toLowerCase();
|
|
return (
|
|
dev.device_id?.toString().includes(s) ||
|
|
dev.device_mac?.toLowerCase().includes(s) ||
|
|
dev.description?.toLowerCase().includes(s) ||
|
|
dev.well_id?.toString().includes(s)
|
|
);
|
|
});
|
|
|
|
const getLocationLabel = (location) => {
|
|
const locations = {
|
|
'-1': 'Unassigned',
|
|
'56': 'Living Room',
|
|
'57': 'Bedroom',
|
|
'58': 'Kitchen',
|
|
'59': 'Bathroom',
|
|
'60': 'Hallway',
|
|
};
|
|
return locations[location?.toString()] || `Location ${location}`;
|
|
};
|
|
|
|
return (
|
|
<AdminLayout>
|
|
<div style={styles.header}>
|
|
<h1 style={styles.title}>Devices</h1>
|
|
<input
|
|
type="text"
|
|
placeholder="Search by ID, MAC, description..."
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
style={styles.search}
|
|
/>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<p style={styles.loading}>Loading...</p>
|
|
) : (
|
|
<div style={styles.table}>
|
|
<div style={styles.tableHeader}>
|
|
<span>Device ID</span>
|
|
<span>MAC Address</span>
|
|
<span>Deployment</span>
|
|
<span>Description</span>
|
|
<span>Location</span>
|
|
<span>Firmware</span>
|
|
</div>
|
|
{filteredDevices.length === 0 ? (
|
|
<div style={styles.empty}>No devices found</div>
|
|
) : (
|
|
filteredDevices.map((dev) => (
|
|
<div key={dev.device_id} style={styles.tableRow}>
|
|
<span style={styles.id}>#{dev.device_id}</span>
|
|
<span style={styles.mac}>{dev.device_mac}</span>
|
|
<span>
|
|
{dev.well_id ? (
|
|
<DeploymentBadge id={dev.well_id} />
|
|
) : (
|
|
<span style={styles.unassigned}>Unassigned</span>
|
|
)}
|
|
</span>
|
|
<span style={styles.description}>{dev.description || '—'}</span>
|
|
<span style={styles.location}>{getLocationLabel(dev.location)}</span>
|
|
<span style={styles.firmware}>{dev.fw_version || '—'}</span>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div style={styles.stats}>
|
|
<span>Total: {devices.length} devices</span>
|
|
<span>Assigned: {devices.filter(d => d.well_id && d.well_id > 0).length}</span>
|
|
<span>Unassigned: {devices.filter(d => !d.well_id || d.well_id <= 0).length}</span>
|
|
</div>
|
|
</AdminLayout>
|
|
);
|
|
}
|
|
|
|
function DeploymentBadge({ id }) {
|
|
return (
|
|
<span style={{
|
|
padding: '4px 10px',
|
|
borderRadius: '12px',
|
|
fontSize: '12px',
|
|
fontWeight: '500',
|
|
background: '#DBEAFE',
|
|
color: '#1E40AF',
|
|
}}>
|
|
Well #{id}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const styles = {
|
|
header: {
|
|
display: 'flex',
|
|
justifyContent: 'space-between',
|
|
alignItems: 'center',
|
|
marginBottom: '24px',
|
|
},
|
|
title: {
|
|
fontSize: '24px',
|
|
fontWeight: '600',
|
|
},
|
|
search: {
|
|
padding: '10px 16px',
|
|
fontSize: '14px',
|
|
border: '1px solid var(--border)',
|
|
borderRadius: '8px',
|
|
width: '280px',
|
|
outline: 'none',
|
|
},
|
|
loading: {
|
|
color: 'var(--text-muted)',
|
|
},
|
|
table: {
|
|
background: 'white',
|
|
borderRadius: '12px',
|
|
overflow: 'hidden',
|
|
},
|
|
tableHeader: {
|
|
display: 'grid',
|
|
gridTemplateColumns: '0.7fr 1.2fr 1fr 1.2fr 1fr 0.8fr',
|
|
gap: '16px',
|
|
padding: '16px 20px',
|
|
background: 'var(--surface)',
|
|
fontSize: '12px',
|
|
fontWeight: '600',
|
|
color: 'var(--text-muted)',
|
|
textTransform: 'uppercase',
|
|
},
|
|
tableRow: {
|
|
display: 'grid',
|
|
gridTemplateColumns: '0.7fr 1.2fr 1fr 1.2fr 1fr 0.8fr',
|
|
gap: '16px',
|
|
padding: '16px 20px',
|
|
borderBottom: '1px solid var(--border)',
|
|
alignItems: 'center',
|
|
fontSize: '14px',
|
|
},
|
|
id: {
|
|
fontWeight: '600',
|
|
color: 'var(--primary)',
|
|
},
|
|
mac: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '13px',
|
|
color: 'var(--text-secondary)',
|
|
},
|
|
description: {
|
|
fontWeight: '500',
|
|
},
|
|
location: {
|
|
color: 'var(--text-muted)',
|
|
fontSize: '13px',
|
|
},
|
|
firmware: {
|
|
fontFamily: 'monospace',
|
|
fontSize: '12px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
unassigned: {
|
|
color: 'var(--text-muted)',
|
|
fontSize: '13px',
|
|
fontStyle: 'italic',
|
|
},
|
|
empty: {
|
|
padding: '48px',
|
|
textAlign: 'center',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
stats: {
|
|
display: 'flex',
|
|
gap: '24px',
|
|
marginTop: '16px',
|
|
fontSize: '13px',
|
|
color: 'var(--text-muted)',
|
|
},
|
|
};
|