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

205 lines
5.1 KiB
JavaScript

'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import AdminLayout from '../../components/AdminLayout';
import { getDeployments } from '../../lib/api';
export default function DeploymentsPage() {
const router = useRouter();
const [deployments, setDeployments] = useState([]);
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
useEffect(() => {
loadDeployments();
}, []);
const loadDeployments = async () => {
try {
const data = await getDeployments();
setDeployments(data.deployments || []);
} catch (err) {
console.error('Failed to load deployments:', err);
} finally {
setLoading(false);
}
};
const filteredDeployments = deployments.filter((dep) => {
if (!search) return true;
const s = search.toLowerCase();
return (
dep.deployment_id?.toString().includes(s) ||
dep.context?.toLowerCase().includes(s) ||
dep.owner?.email?.toLowerCase().includes(s)
);
});
const getGenderLabel = (gender) => {
switch (gender) {
case 1: return 'Male';
case 2: return 'Female';
default: return '—';
}
};
return (
<AdminLayout>
<div style={styles.header}>
<h1 style={styles.title}>Deployments</h1>
<input
type="text"
placeholder="Search by ID, context, or owner..."
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>ID</span>
<span>Owner</span>
<span>Context</span>
<span>Persons</span>
<span>Gender</span>
<span>Devices</span>
<span>Actions</span>
</div>
{filteredDeployments.length === 0 ? (
<div style={styles.empty}>No deployments found</div>
) : (
filteredDeployments.map((dep) => (
<div key={dep.deployment_id} style={styles.tableRow}>
<span style={styles.id}>#{dep.deployment_id}</span>
<span style={styles.owner}>
{dep.owner ? dep.owner.email : '—'}
</span>
<span style={styles.context}>{dep.context || '—'}</span>
<span>{dep.persons || 0}</span>
<span>{getGenderLabel(dep.gender)}</span>
<span>
<DevicesBadge count={dep.device_count} />
</span>
<span>
<button
onClick={() => router.push(`/deployments/${dep.deployment_id}`)}
style={styles.viewBtn}
>
View
</button>
</span>
</div>
))
)}
</div>
)}
<div style={styles.stats}>
<span>Total: {deployments.length} deployments</span>
<span>With devices: {deployments.filter(d => d.device_count > 0).length}</span>
</div>
</AdminLayout>
);
}
function DevicesBadge({ count }) {
return (
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500',
background: count > 0 ? '#D1FAE5' : '#F3F4F6',
color: count > 0 ? '#065F46' : '#6B7280',
}}>
{count} devices
</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.5fr 1.5fr 1.5fr 0.7fr 0.7fr 0.8fr 0.6fr',
gap: '16px',
padding: '16px 20px',
background: 'var(--surface)',
fontSize: '12px',
fontWeight: '600',
color: 'var(--text-muted)',
textTransform: 'uppercase',
},
tableRow: {
display: 'grid',
gridTemplateColumns: '0.5fr 1.5fr 1.5fr 0.7fr 0.7fr 0.8fr 0.6fr',
gap: '16px',
padding: '16px 20px',
borderBottom: '1px solid var(--border)',
alignItems: 'center',
fontSize: '14px',
},
id: {
fontWeight: '600',
color: 'var(--primary)',
},
owner: {
color: 'var(--text-secondary)',
fontSize: '13px',
},
context: {
fontWeight: '500',
},
viewBtn: {
padding: '6px 12px',
background: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
},
empty: {
padding: '48px',
textAlign: 'center',
color: 'var(--text-muted)',
},
stats: {
display: 'flex',
gap: '24px',
marginTop: '16px',
fontSize: '13px',
color: 'var(--text-muted)',
},
};