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

524 lines
14 KiB
JavaScript

'use client';
import { useEffect, useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import AdminLayout from '../../../components/AdminLayout';
import { getUser } from '../../../lib/api';
export default function UserDetailPage() {
const { id } = useParams();
const router = useRouter();
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
if (id) loadUser();
}, [id]);
const loadUser = async () => {
try {
const data = await getUser(id);
setUser(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
if (loading) {
return (
<AdminLayout>
<p style={styles.loading}>Loading...</p>
</AdminLayout>
);
}
if (error || !user) {
return (
<AdminLayout>
<div style={styles.error}>
<p>User not found</p>
<button onClick={() => router.push('/users')} style={styles.backBtn}>
Back to Users
</button>
</div>
</AdminLayout>
);
}
const userName = user.first_name || user.last_name
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
: 'Unnamed User';
return (
<AdminLayout>
{/* Header */}
<div style={styles.header}>
<button onClick={() => router.push('/users')} style={styles.backLink}>
Back to Users
</button>
<div style={styles.titleRow}>
<h1 style={styles.title}>{userName}</h1>
<div style={styles.badges}>
{user.role === 'admin' && <Badge text="Admin" color="#F59E0B" />}
{user.is_beneficiary && <Badge text="Beneficiary" color="#10B981" />}
{user.is_caretaker && <Badge text="Caretaker" color="#6366F1" />}
</div>
</div>
<p style={styles.email}>{user.email}</p>
</div>
{/* Info Cards */}
<div style={styles.grid}>
{/* Basic Info */}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Basic Information</h3>
<InfoRow label="Email" value={user.email} />
<InfoRow label="Phone" value={user.phone || '—'} />
<InfoRow label="Role" value={user.role || 'user'} />
<InfoRow label="Joined" value={new Date(user.created_at).toLocaleDateString()} />
</div>
{/* Stats */}
<div style={styles.card}>
<h3 style={styles.cardTitle}>Statistics</h3>
<div style={styles.statsGrid}>
<StatBox label="Deployments" value={user.deployments?.length || 0} color="#3B82F6" />
<StatBox label="Devices" value={user.devices?.length || 0} color="#10B981" />
<StatBox label="Orders" value={user.orders?.length || 0} color="#F59E0B" />
<StatBox label="Subscriptions" value={user.subscriptions?.length || 0} color="#8B5CF6" />
</div>
</div>
</div>
{/* Relationships */}
{(user.watched_by?.length > 0 || user.watches?.length > 0) && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Relationships</h2>
<div style={styles.grid}>
{user.watched_by?.length > 0 && (
<div style={styles.card}>
<h3 style={styles.cardTitle}>Watched by ({user.watched_by.length})</h3>
<div style={styles.relationList}>
{user.watched_by.map((rel, idx) => (
<div key={idx} style={styles.relationItem}>
<div>
<div style={styles.relationName}>
{rel.user?.first_name || rel.user?.last_name
? `${rel.user.first_name || ''} ${rel.user.last_name || ''}`.trim()
: rel.user?.email || 'Unknown'}
</div>
<div style={styles.relationEmail}>{rel.user?.email}</div>
</div>
<RoleBadge role={rel.role} />
</div>
))}
</div>
</div>
)}
{user.watches?.length > 0 && (
<div style={styles.card}>
<h3 style={styles.cardTitle}>Watches ({user.watches.length})</h3>
<div style={styles.relationList}>
{user.watches.map((rel, idx) => (
<div key={idx} style={styles.relationItem}>
<div>
<div style={styles.relationName}>
{rel.user?.first_name || rel.user?.last_name
? `${rel.user.first_name || ''} ${rel.user.last_name || ''}`.trim()
: rel.user?.email || 'Unknown'}
</div>
<div style={styles.relationEmail}>{rel.user?.email}</div>
</div>
<RoleBadge role={rel.role} />
</div>
))}
</div>
</div>
)}
</div>
</div>
)}
{/* Deployments */}
{user.deployments?.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Deployments</h2>
<div style={styles.table}>
<div style={styles.tableHeader}>
<span>ID</span>
<span>Address</span>
<span>Status</span>
<span>Actions</span>
</div>
{user.deployments.map((dep) => (
<div key={dep.deployment_id} style={styles.tableRow}>
<span style={styles.mono}>#{dep.deployment_id}</span>
<span>{dep.address || '—'}</span>
<span>
<StatusBadge status={dep.status} />
</span>
<span>
<button
onClick={() => router.push(`/deployments/${dep.deployment_id}`)}
style={styles.viewBtn}
>
View
</button>
</span>
</div>
))}
</div>
</div>
)}
{/* Devices */}
{user.devices?.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Devices</h2>
<div style={styles.table}>
<div style={styles.tableHeader}>
<span>Device ID</span>
<span>Type</span>
<span>Status</span>
<span>Last Seen</span>
</div>
{user.devices.map((dev) => (
<div key={dev.device_id} style={styles.tableRow}>
<span style={styles.mono}>{dev.device_id}</span>
<span>{dev.device_type || '—'}</span>
<span>
<StatusBadge status={dev.status} />
</span>
<span style={styles.muted}>
{dev.last_seen_at
? new Date(dev.last_seen_at).toLocaleString()
: '—'}
</span>
</div>
))}
</div>
</div>
)}
{/* Orders */}
{user.orders?.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Orders</h2>
<div style={styles.table}>
<div style={styles.tableHeader}>
<span>Order #</span>
<span>Status</span>
<span>Amount</span>
<span>Date</span>
</div>
{user.orders.map((order) => (
<div key={order.id} style={styles.tableRow}>
<span style={styles.mono}>{order.order_number}</span>
<span>
<StatusBadge status={order.status} />
</span>
<span>${((order.total_amount || 0) / 100).toFixed(2)}</span>
<span style={styles.muted}>
{new Date(order.created_at).toLocaleDateString()}
</span>
</div>
))}
</div>
</div>
)}
{/* Subscriptions */}
{user.subscriptions?.length > 0 && (
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Subscriptions</h2>
<div style={styles.table}>
<div style={styles.tableHeader}>
<span>Plan</span>
<span>Status</span>
<span>Started</span>
<span>Expires</span>
</div>
{user.subscriptions.map((sub) => (
<div key={sub.id} style={styles.tableRow}>
<span style={{ textTransform: 'capitalize', fontWeight: 500 }}>
{sub.plan}
</span>
<span>
<StatusBadge status={sub.status} />
</span>
<span style={styles.muted}>
{new Date(sub.created_at).toLocaleDateString()}
</span>
<span style={styles.muted}>
{sub.expires_at ? new Date(sub.expires_at).toLocaleDateString() : '—'}
</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 StatBox({ label, value, color }) {
return (
<div style={styles.statBox}>
<div style={{ ...styles.statValue, color }}>{value}</div>
<div style={styles.statLabel}>{label}</div>
</div>
);
}
function Badge({ text, color }) {
return (
<span style={{
padding: '4px 12px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '600',
background: `${color}20`,
color: color,
}}>
{text}
</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 StatusBadge({ status }) {
const colors = {
active: '#10B981',
paid: '#3B82F6',
shipped: '#8B5CF6',
delivered: '#10B981',
installed: '#059669',
canceled: '#EF4444',
pending: '#F59E0B',
};
const color = colors[status] || '#6B7280';
return (
<span style={{
padding: '3px 10px',
borderRadius: '8px',
fontSize: '11px',
fontWeight: '500',
background: `${color}15`,
color: color,
textTransform: 'capitalize',
}}>
{status || 'unknown'}
</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,
},
badges: {
display: 'flex',
gap: '8px',
},
email: {
color: 'var(--text-secondary)',
marginTop: '4px',
},
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',
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '16px',
},
statBox: {
textAlign: 'center',
padding: '12px',
background: 'var(--surface)',
borderRadius: '8px',
},
statValue: {
fontSize: '24px',
fontWeight: '700',
},
statLabel: {
fontSize: '12px',
color: 'var(--text-muted)',
marginTop: '4px',
},
section: {
marginTop: '32px',
},
sectionTitle: {
fontSize: '18px',
fontWeight: '600',
marginBottom: '16px',
},
relationList: {
display: 'flex',
flexDirection: 'column',
gap: '12px',
},
relationItem: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '12px',
background: 'var(--surface)',
borderRadius: '8px',
},
relationName: {
fontWeight: '500',
fontSize: '14px',
},
relationEmail: {
fontSize: '13px',
color: 'var(--text-muted)',
},
table: {
background: 'white',
borderRadius: '12px',
overflow: 'hidden',
},
tableHeader: {
display: 'grid',
gridTemplateColumns: '1fr 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: '1fr 2fr 1fr 1fr',
gap: '16px',
padding: '14px 20px',
borderBottom: '1px solid var(--border)',
alignItems: 'center',
fontSize: '14px',
},
mono: {
fontFamily: 'monospace',
fontSize: '13px',
},
muted: {
color: 'var(--text-muted)',
fontSize: '13px',
},
viewBtn: {
padding: '6px 12px',
background: 'var(--primary)',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '12px',
cursor: 'pointer',
},
};