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

239 lines
6.0 KiB
JavaScript

'use client';
import { useEffect, useState } from 'react';
import AdminLayout from '../../components/AdminLayout';
import { getStats, getOrders } from '../../lib/api';
export default function DashboardPage() {
const [stats, setStats] = useState(null);
const [recentOrders, setRecentOrders] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
loadData();
}, []);
const loadData = async () => {
try {
const [statsData, ordersData] = await Promise.all([
getStats(),
getOrders(),
]);
setStats(statsData);
setRecentOrders(ordersData.orders?.slice(0, 5) || []);
} catch (err) {
console.error('Failed to load stats:', err);
} finally {
setLoading(false);
}
};
return (
<AdminLayout>
<h1 style={styles.title}>Dashboard</h1>
{loading ? (
<p style={styles.loading}>Loading...</p>
) : (
<>
{/* Stats Cards */}
<div style={styles.statsGrid}>
<StatCard
label="Orders Today"
value={stats?.orders?.today || 0}
color="var(--primary)"
/>
<StatCard
label="Total Orders"
value={stats?.orders?.total || 0}
color="var(--text-secondary)"
/>
<StatCard
label="Premium Subs"
value={stats?.subscriptions?.premium || 0}
color="var(--success)"
/>
<StatCard
label="MRR"
value={`$${(stats?.mrr || 0).toFixed(2)}`}
color="var(--warning)"
/>
</div>
{/* Order Status Breakdown */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Orders by Status</h2>
<div style={styles.statusGrid}>
{Object.entries(stats?.orders?.byStatus || {}).map(([status, count]) => (
<div key={status} style={styles.statusItem}>
<span style={styles.statusLabel}>{status}</span>
<span style={styles.statusValue}>{count}</span>
</div>
))}
</div>
</div>
{/* Recent Orders */}
<div style={styles.section}>
<h2 style={styles.sectionTitle}>Recent Orders</h2>
{recentOrders.length === 0 ? (
<p style={styles.empty}>No orders yet</p>
) : (
<div style={styles.table}>
<div style={styles.tableHeader}>
<span>Order</span>
<span>Status</span>
<span>Amount</span>
<span>Date</span>
</div>
{recentOrders.map((order) => (
<div key={order.id} style={styles.tableRow}>
<span style={styles.orderId}>#{order.order_number || order.id}</span>
<span>
<StatusBadge status={order.status} />
</span>
<span>${((order.total_amount || 0) / 100).toFixed(2)}</span>
<span style={styles.date}>
{new Date(order.created_at).toLocaleDateString()}
</span>
</div>
))}
</div>
)}
</div>
</>
)}
</AdminLayout>
);
}
function StatCard({ label, value, color }) {
return (
<div style={styles.statCard}>
<span style={styles.statLabel}>{label}</span>
<span style={{ ...styles.statValue, color }}>{value}</span>
</div>
);
}
function StatusBadge({ status }) {
const colors = {
paid: { bg: '#DBEAFE', color: '#1E40AF' },
preparing: { bg: '#FEF3C7', color: '#92400E' },
shipped: { bg: '#D1FAE5', color: '#065F46' },
delivered: { bg: '#E0E7FF', color: '#3730A3' },
installed: { bg: '#D1FAE5', color: '#065F46' },
};
const style = colors[status] || { bg: '#F3F4F6', color: '#6B7280' };
return (
<span style={{
padding: '4px 10px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500',
background: style.bg,
color: style.color,
}}>
{status}
</span>
);
}
const styles = {
title: {
fontSize: '24px',
fontWeight: '600',
marginBottom: '24px',
},
loading: {
color: 'var(--text-muted)',
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(4, 1fr)',
gap: '16px',
marginBottom: '32px',
},
statCard: {
background: 'white',
padding: '20px',
borderRadius: '12px',
display: 'flex',
flexDirection: 'column',
gap: '8px',
},
statLabel: {
fontSize: '13px',
color: 'var(--text-muted)',
},
statValue: {
fontSize: '28px',
fontWeight: '600',
},
section: {
background: 'white',
borderRadius: '12px',
padding: '24px',
marginBottom: '24px',
},
sectionTitle: {
fontSize: '16px',
fontWeight: '600',
marginBottom: '16px',
},
statusGrid: {
display: 'flex',
gap: '24px',
flexWrap: 'wrap',
},
statusItem: {
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
statusLabel: {
fontSize: '12px',
color: 'var(--text-muted)',
textTransform: 'capitalize',
},
statusValue: {
fontSize: '20px',
fontWeight: '600',
},
empty: {
color: 'var(--text-muted)',
fontSize: '14px',
},
table: {
display: 'flex',
flexDirection: 'column',
},
tableHeader: {
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr 1fr',
gap: '16px',
padding: '12px 0',
borderBottom: '1px solid var(--border)',
fontSize: '12px',
fontWeight: '600',
color: 'var(--text-muted)',
textTransform: 'uppercase',
},
tableRow: {
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr 1fr',
gap: '16px',
padding: '16px 0',
borderBottom: '1px solid var(--border)',
alignItems: 'center',
fontSize: '14px',
},
orderId: {
fontWeight: '500',
},
date: {
color: 'var(--text-muted)',
},
};