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>
397 lines
10 KiB
JavaScript
397 lines
10 KiB
JavaScript
'use client';
|
||
|
||
import { useEffect, useState } from 'react';
|
||
import { useRouter } from 'next/navigation';
|
||
import AdminLayout from '../../components/AdminLayout';
|
||
import { getUsers } from '../../lib/api';
|
||
|
||
export default function UsersPage() {
|
||
const router = useRouter();
|
||
const [users, setUsers] = useState([]);
|
||
const [search, setSearch] = useState('');
|
||
const [filter, setFilter] = useState('all');
|
||
const [loading, setLoading] = useState(true);
|
||
const [expandedUser, setExpandedUser] = useState(null);
|
||
|
||
useEffect(() => {
|
||
loadUsers();
|
||
}, []);
|
||
|
||
const loadUsers = async () => {
|
||
try {
|
||
const data = await getUsers();
|
||
setUsers(data.users || []);
|
||
} catch (err) {
|
||
console.error('Failed to load users:', err);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const filteredUsers = users.filter((user) => {
|
||
// Search filter
|
||
if (search) {
|
||
const s = search.toLowerCase();
|
||
const matchesSearch =
|
||
user.email?.toLowerCase().includes(s) ||
|
||
user.first_name?.toLowerCase().includes(s) ||
|
||
user.last_name?.toLowerCase().includes(s);
|
||
if (!matchesSearch) return false;
|
||
}
|
||
|
||
// Type filter
|
||
if (filter === 'beneficiaries') return user.is_beneficiary;
|
||
if (filter === 'caretakers') return user.is_caretaker;
|
||
if (filter === 'admins') return user.role === 'admin';
|
||
return true;
|
||
});
|
||
|
||
const stats = {
|
||
total: users.length,
|
||
beneficiaries: users.filter(u => u.is_beneficiary).length,
|
||
caretakers: users.filter(u => u.is_caretaker).length,
|
||
admins: users.filter(u => u.role === 'admin').length,
|
||
};
|
||
|
||
return (
|
||
<AdminLayout>
|
||
<div style={styles.header}>
|
||
<h1 style={styles.title}>Users</h1>
|
||
<div style={styles.controls}>
|
||
<select
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
style={styles.filterSelect}
|
||
>
|
||
<option value="all">All Users</option>
|
||
<option value="beneficiaries">Beneficiaries</option>
|
||
<option value="caretakers">Caretakers</option>
|
||
<option value="admins">Admins</option>
|
||
</select>
|
||
<input
|
||
type="text"
|
||
placeholder="Search by email or name..."
|
||
value={search}
|
||
onChange={(e) => setSearch(e.target.value)}
|
||
style={styles.search}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Stats */}
|
||
<div style={styles.statsGrid}>
|
||
<StatCard label="Total Users" value={stats.total} icon="👥" />
|
||
<StatCard label="Beneficiaries" value={stats.beneficiaries} icon="🧓" color="#10B981" />
|
||
<StatCard label="Caretakers" value={stats.caretakers} icon="👨⚕️" color="#6366F1" />
|
||
<StatCard label="Admins" value={stats.admins} icon="🛡️" color="#F59E0B" />
|
||
</div>
|
||
|
||
{loading ? (
|
||
<p style={styles.loading}>Loading...</p>
|
||
) : filteredUsers.length === 0 ? (
|
||
<div style={styles.empty}>
|
||
<p>No users found</p>
|
||
</div>
|
||
) : (
|
||
<div style={styles.userList}>
|
||
{filteredUsers.map((user) => (
|
||
<UserCard
|
||
key={user.id}
|
||
user={user}
|
||
expanded={expandedUser === user.id}
|
||
onToggle={() => setExpandedUser(expandedUser === user.id ? null : user.id)}
|
||
onView={() => router.push(`/users/${user.id}`)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</AdminLayout>
|
||
);
|
||
}
|
||
|
||
function StatCard({ label, value, icon, color = '#3B82F6' }) {
|
||
return (
|
||
<div style={styles.statCard}>
|
||
<div style={styles.statIcon}>{icon}</div>
|
||
<div style={styles.statInfo}>
|
||
<div style={{ ...styles.statValue, color }}>{value}</div>
|
||
<div style={styles.statLabel}>{label}</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function UserCard({ user, expanded, onToggle, onView }) {
|
||
const userName = user.first_name || user.last_name
|
||
? `${user.first_name || ''} ${user.last_name || ''}`.trim()
|
||
: 'Unnamed User';
|
||
|
||
const hasRelationships = (user.watches?.length > 0) || (user.watched_by?.length > 0);
|
||
|
||
return (
|
||
<div style={styles.userCard}>
|
||
<div style={styles.userMain}>
|
||
<div style={styles.userInfo} onClick={hasRelationships ? onToggle : undefined}>
|
||
<div style={styles.userName}>{userName}</div>
|
||
<div style={styles.userEmail}>{user.email}</div>
|
||
{user.phone && <div style={styles.userPhone}>{user.phone}</div>}
|
||
</div>
|
||
|
||
<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 style={styles.userMeta}>
|
||
<span style={styles.date}>
|
||
Joined {new Date(user.created_at).toLocaleDateString()}
|
||
</span>
|
||
{hasRelationships && (
|
||
<span style={styles.expandIcon} onClick={onToggle}>{expanded ? '▼' : '▶'}</span>
|
||
)}
|
||
<button onClick={onView} style={styles.viewBtn}>View</button>
|
||
</div>
|
||
</div>
|
||
|
||
{expanded && hasRelationships && (
|
||
<div style={styles.relationships}>
|
||
{user.watched_by?.length > 0 && (
|
||
<div style={styles.relationSection}>
|
||
<div style={styles.relationTitle}>👀 Watched by:</div>
|
||
<div style={styles.relationList}>
|
||
{user.watched_by.map((rel, idx) => (
|
||
<div key={idx} style={styles.relationItem}>
|
||
<span style={styles.relationEmail}>{rel.accessor_email}</span>
|
||
<RoleBadge role={rel.role} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{user.watches?.length > 0 && (
|
||
<div style={styles.relationSection}>
|
||
<div style={styles.relationTitle}>🔍 Watches:</div>
|
||
<div style={styles.relationList}>
|
||
{user.watches.map((rel, idx) => (
|
||
<div key={idx} style={styles.relationItem}>
|
||
<span style={styles.relationEmail}>{rel.beneficiary_email}</span>
|
||
<RoleBadge role={rel.role} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Badge({ text, color }) {
|
||
return (
|
||
<span style={{
|
||
padding: '4px 10px',
|
||
borderRadius: '12px',
|
||
fontSize: '11px',
|
||
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: '2px 8px',
|
||
borderRadius: '8px',
|
||
fontSize: '10px',
|
||
fontWeight: '500',
|
||
background: `${color}15`,
|
||
color: color,
|
||
textTransform: 'capitalize',
|
||
}}>
|
||
{role}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
const styles = {
|
||
header: {
|
||
display: 'flex',
|
||
justifyContent: 'space-between',
|
||
alignItems: 'center',
|
||
marginBottom: '24px',
|
||
flexWrap: 'wrap',
|
||
gap: '16px',
|
||
},
|
||
title: {
|
||
fontSize: '24px',
|
||
fontWeight: '600',
|
||
},
|
||
controls: {
|
||
display: 'flex',
|
||
gap: '12px',
|
||
alignItems: 'center',
|
||
},
|
||
filterSelect: {
|
||
padding: '10px 16px',
|
||
fontSize: '14px',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: '8px',
|
||
background: 'white',
|
||
cursor: 'pointer',
|
||
outline: 'none',
|
||
},
|
||
search: {
|
||
padding: '10px 16px',
|
||
fontSize: '14px',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: '8px',
|
||
width: '240px',
|
||
outline: 'none',
|
||
},
|
||
statsGrid: {
|
||
display: 'grid',
|
||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||
gap: '16px',
|
||
marginBottom: '24px',
|
||
},
|
||
statCard: {
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
padding: '20px',
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '16px',
|
||
},
|
||
statIcon: {
|
||
fontSize: '28px',
|
||
},
|
||
statInfo: {
|
||
flex: 1,
|
||
},
|
||
statValue: {
|
||
fontSize: '28px',
|
||
fontWeight: '700',
|
||
},
|
||
statLabel: {
|
||
fontSize: '13px',
|
||
color: 'var(--text-muted)',
|
||
},
|
||
loading: {
|
||
color: 'var(--text-muted)',
|
||
textAlign: 'center',
|
||
padding: '48px',
|
||
},
|
||
empty: {
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
padding: '48px',
|
||
textAlign: 'center',
|
||
color: 'var(--text-muted)',
|
||
},
|
||
userList: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '12px',
|
||
},
|
||
userCard: {
|
||
background: 'white',
|
||
borderRadius: '12px',
|
||
overflow: 'hidden',
|
||
},
|
||
userMain: {
|
||
display: 'grid',
|
||
gridTemplateColumns: '1fr auto auto',
|
||
gap: '20px',
|
||
padding: '20px',
|
||
alignItems: 'center',
|
||
cursor: 'pointer',
|
||
},
|
||
userInfo: {
|
||
minWidth: 0,
|
||
},
|
||
userName: {
|
||
fontSize: '15px',
|
||
fontWeight: '600',
|
||
marginBottom: '4px',
|
||
},
|
||
userEmail: {
|
||
fontSize: '14px',
|
||
color: 'var(--text-secondary)',
|
||
},
|
||
userPhone: {
|
||
fontSize: '13px',
|
||
color: 'var(--text-muted)',
|
||
marginTop: '2px',
|
||
},
|
||
badges: {
|
||
display: 'flex',
|
||
gap: '8px',
|
||
flexWrap: 'wrap',
|
||
},
|
||
userMeta: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '12px',
|
||
color: 'var(--text-muted)',
|
||
},
|
||
date: {
|
||
fontSize: '13px',
|
||
},
|
||
expandIcon: {
|
||
fontSize: '12px',
|
||
color: 'var(--text-muted)',
|
||
cursor: 'pointer',
|
||
},
|
||
viewBtn: {
|
||
padding: '6px 12px',
|
||
background: 'var(--primary)',
|
||
color: 'white',
|
||
border: 'none',
|
||
borderRadius: '6px',
|
||
fontSize: '12px',
|
||
cursor: 'pointer',
|
||
},
|
||
relationships: {
|
||
background: 'var(--surface)',
|
||
padding: '16px 20px',
|
||
borderTop: '1px solid var(--border)',
|
||
},
|
||
relationSection: {
|
||
marginBottom: '12px',
|
||
},
|
||
relationTitle: {
|
||
fontSize: '12px',
|
||
fontWeight: '600',
|
||
color: 'var(--text-muted)',
|
||
marginBottom: '8px',
|
||
},
|
||
relationList: {
|
||
display: 'flex',
|
||
flexDirection: 'column',
|
||
gap: '6px',
|
||
},
|
||
relationItem: {
|
||
display: 'flex',
|
||
alignItems: 'center',
|
||
gap: '10px',
|
||
fontSize: '13px',
|
||
},
|
||
relationEmail: {
|
||
color: 'var(--text-secondary)',
|
||
},
|
||
};
|