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>
524 lines
14 KiB
JavaScript
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',
|
|
},
|
|
};
|