Sergei ec63a2c1e2 Add admin panel, optimized API, OTP auth, migrations
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>
2025-12-20 11:05:39 -08:00

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)',
},
};