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>
239 lines
6.0 KiB
JavaScript
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)',
|
|
},
|
|
};
|