Feature: Create comprehensive admin dashboard

- Add /admin/dashboard as main admin homepage
- Display key statistics: active orders, revenue, delivered count, design reviews
- Show active orders with status, customer, and price
- Display top 10 most active customers with order counts and totals
- Include alerts for failed orders and pending design reviews
- Add quick action buttons for common admin tasks
- Add button to return to main site
- Sticky admin navigation bar across top
- Redirect admin login to dashboard instead of orders
- Update password change redirects to point to dashboard

Dashboard provides complete overview of business health and quick access to all admin functions.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Andymick
2026-07-19 10:27:02 +01:00
co-authored by Claude Haiku 4.5
parent df4a95b3c8
commit aa44a23f17
6 changed files with 242 additions and 5 deletions
+1 -1
View File
@@ -45,5 +45,5 @@ export async function changeAdminPassword(formData: FormData) {
const username = dbAdmin?.username ?? process.env.ADMIN_USER ?? 'admin';
await createOrUpdateAdminUser(username, hashedPassword);
redirect('/admin/orders?success=password_changed');
redirect('/admin/dashboard?success=password_changed');
}
+2 -2
View File
@@ -18,8 +18,8 @@ export default async function ChangeAdminPasswordPage({ searchParams }: { search
return (
<div className="mx-auto max-w-2xl px-6 py-12">
<Link href="/admin/orders" className="tag-label text-muted hover:text-clay">
Back to orders
<Link href="/admin/dashboard" className="tag-label text-muted hover:text-clay">
Back to dashboard
</Link>
<h1 className="mt-6 font-display text-3xl">Change admin password</h1>
+235
View File
@@ -0,0 +1,235 @@
import Link from 'next/link';
import { prisma } from '@/lib/prisma';
import { isAdminAuthenticated } from '@/lib/adminAuth';
import AdminNav from '@/components/AdminNav';
export default async function AdminDashboard() {
const isAdmin = await isAdminAuthenticated();
if (!isAdmin) {
return <div className="px-6 py-12 text-center">Unauthorized</div>;
}
// Fetch statistics
const orders = await prisma.order.findMany({
include: { items: true, customer: true },
orderBy: { createdAt: 'desc' },
});
const activeOrders = orders.filter((o) => ['PENDING', 'PAID'].includes(o.status));
const totalRevenue = orders
.filter((o) => o.status === 'PAID')
.reduce((sum, o) => sum + o.total, 0);
const deliveredCount = orders.filter((o) => o.status === 'DELIVERED').length;
// Get top 10 active customers (by order count)
const customers = await prisma.customer.findMany({
include: { orders: true },
});
const topCustomers = customers
.map((c) => ({
id: c.id,
name: c.name || c.email,
email: c.email,
orderCount: c.orders.length,
totalSpent: c.orders.reduce((sum, o) => sum + o.total, 0),
}))
.sort((a, b) => b.orderCount - a.orderCount)
.slice(0, 10);
// Get pending design approvals
const designApprovals = await prisma.designApproval.findMany({
where: { status: 'IN_REVIEW' },
include: { product: true },
});
// Get failed orders
const failedOrders = orders.filter((o) => o.status === 'FAILED');
// Recent orders (last 5)
const recentOrders = orders.slice(0, 5);
return (
<div className="min-h-screen bg-surface-1">
{/* Header */}
<div className="border-b border-line bg-surface">
<div className="mx-auto max-w-6xl px-6 py-6">
<div className="flex items-baseline justify-between">
<h1 className="font-display text-3xl">Admin Dashboard</h1>
<Link href="/" className="text-sm text-clay hover:text-clay-dark font-medium">
Back to site
</Link>
</div>
<p className="mt-2 text-sm text-muted">
Overview of orders, customers, and key metrics
</p>
</div>
</div>
{/* Main Navigation */}
<div className="border-b border-line bg-surface sticky top-0 z-10">
<div className="mx-auto max-w-6xl px-6">
<AdminNav />
</div>
</div>
<div className="mx-auto max-w-6xl px-6 py-12">
{/* Key Statistics */}
<div className="grid gap-4 sm:grid-cols-4 mb-12">
<div className="border border-line bg-surface p-6 rounded">
<p className="tag-label text-muted">Active Orders</p>
<p className="mt-3 font-display text-3xl">{activeOrders.length}</p>
<p className="mt-1 text-xs text-muted">Pending or paid</p>
</div>
<div className="border border-line bg-surface p-6 rounded">
<p className="tag-label text-muted">Revenue (Paid)</p>
<p className="mt-3 font-display text-3xl">£{(totalRevenue / 100).toFixed(2)}</p>
<p className="mt-1 text-xs text-muted">{orders.filter((o) => o.status === 'PAID').length} orders</p>
</div>
<div className="border border-line bg-surface p-6 rounded">
<p className="tag-label text-muted">Delivered</p>
<p className="mt-3 font-display text-3xl">{deliveredCount}</p>
<p className="mt-1 text-xs text-muted">Completed orders</p>
</div>
<div className="border border-line bg-surface p-6 rounded">
<p className="tag-label text-muted">Design Reviews</p>
<p className="mt-3 font-display text-3xl">{designApprovals.length}</p>
<p className="mt-1 text-xs text-muted">Awaiting approval</p>
</div>
</div>
{/* Main Content Grid */}
<div className="grid gap-8 lg:grid-cols-3">
{/* Active Orders */}
<div className="lg:col-span-2">
<div className="border border-line bg-surface p-6 rounded">
<div className="flex items-baseline justify-between mb-4">
<h2 className="font-display text-2xl">Active Orders</h2>
<Link href="/admin/orders" className="text-sm text-clay hover:text-clay-dark">
View all
</Link>
</div>
{activeOrders.length === 0 ? (
<p className="text-sm text-muted py-8">No active orders</p>
) : (
<div className="divide-y divide-line">
{activeOrders.slice(0, 8).map((order) => (
<Link
key={order.id}
href={`/admin/orders/${order.id}`}
className="flex items-center justify-between py-3 hover:bg-surface-1 px-2 -mx-2 rounded"
>
<div className="flex-1">
<p className="font-medium text-sm">
Order #{order.id.slice(0, 8).toUpperCase()}
</p>
<p className="text-xs text-muted">
{order.email || order.guestName || 'Guest'} · {order.items.length} item
{order.items.length !== 1 ? 's' : ''}
</p>
</div>
<div className="text-right">
<p className="font-mono text-sm">£{(order.total / 100).toFixed(2)}</p>
<span
className={`tag-label text-xs px-2 py-1 rounded-full inline-block mt-1 ${
order.status === 'PENDING'
? 'bg-splash-orange/10 text-splash-orange'
: 'bg-splash-blue/10 text-splash-blue'
}`}
>
{order.status === 'PENDING' ? 'Awaiting payment' : 'Paid'}
</span>
</div>
</Link>
))}
</div>
)}
</div>
{/* Alerts */}
{failedOrders.length > 0 && (
<div className="border border-splash-pink/40 bg-splash-pink/5 p-4 rounded mt-8">
<p className="font-medium text-splash-pink mb-2"> {failedOrders.length} Failed Order{failedOrders.length !== 1 ? 's' : ''}</p>
<p className="text-sm text-muted">
{failedOrders.length} order{failedOrders.length !== 1 ? 's' : ''} failed payment processing.{' '}
<Link href="/admin/orders" className="text-splash-pink hover:text-splash-pink-dark">
Review
</Link>
</p>
</div>
)}
{designApprovals.length > 0 && (
<div className="border border-splash-pink/40 bg-splash-pink/5 p-4 rounded mt-4">
<p className="font-medium text-splash-pink mb-2"> {designApprovals.length} Design Review{designApprovals.length !== 1 ? 's' : ''}</p>
<p className="text-sm text-muted">
{designApprovals.length} design{designApprovals.length !== 1 ? 's' : ''} awaiting approval.{' '}
<Link href="/admin/designs" className="text-splash-pink hover:text-splash-pink-dark">
Review
</Link>
</p>
</div>
)}
</div>
{/* Sidebar */}
<div className="space-y-8">
{/* Top Customers */}
<div className="border border-line bg-surface p-6 rounded">
<h3 className="font-display text-xl mb-4">Top 10 Customers</h3>
{topCustomers.length === 0 ? (
<p className="text-sm text-muted">No customers yet</p>
) : (
<div className="space-y-3">
{topCustomers.map((customer, idx) => (
<div key={customer.id} className="pb-3 border-b border-line last:border-b-0 last:pb-0">
<p className="font-medium text-sm">
{idx + 1}. {customer.name}
</p>
<p className="text-xs text-muted">{customer.email}</p>
<p className="text-xs text-muted mt-1">
{customer.orderCount} order{customer.orderCount !== 1 ? 's' : ''} · £
{(customer.totalSpent / 100).toFixed(2)}
</p>
</div>
))}
</div>
)}
</div>
{/* Quick Actions */}
<div className="border border-line bg-surface p-6 rounded">
<h3 className="font-display text-xl mb-4">Quick Actions</h3>
<div className="space-y-2">
<Link
href="/admin/orders"
className="block w-full text-center bg-clay text-paper px-4 py-2 text-sm font-medium rounded hover:bg-clay-dark"
>
View All Orders
</Link>
<Link
href="/admin/customers"
className="block w-full text-center border border-line text-ink px-4 py-2 text-sm font-medium rounded hover:bg-surface-1"
>
Email Customers
</Link>
<Link
href="/admin/designs"
className="block w-full text-center border border-line text-ink px-4 py-2 text-sm font-medium rounded hover:bg-surface-1"
>
Design Reviews
</Link>
<Link
href="/admin/products/new"
className="block w-full text-center border border-line text-ink px-4 py-2 text-sm font-medium rounded hover:bg-surface-1"
>
Add Product
</Link>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+1 -1
View File
@@ -9,7 +9,7 @@ import { getAdminUser, verifyPassword } from '@/lib/adminAuth';
function safeNext(next: FormDataEntryValue | null) {
const path = String(next ?? '');
return path.startsWith('/') && !path.startsWith('//') ? path : '/admin/orders';
return path.startsWith('/') && !path.startsWith('//') ? path : '/admin/dashboard';
}
export async function loginAdmin(formData: FormData) {
+1 -1
View File
@@ -7,7 +7,7 @@ const ERROR_MESSAGES: Record<string, string> = {
export default function AdminLoginPage({ searchParams }: { searchParams: { error?: string; next?: string } }) {
const error = searchParams.error ? (ERROR_MESSAGES[searchParams.error] ?? 'Something went wrong.') : null;
const next = searchParams.next ?? '/admin/orders';
const next = searchParams.next ?? '/admin/dashboard';
return (
<div className="mx-auto max-w-md px-6 py-16">
+2
View File
@@ -27,6 +27,8 @@ const SUCCESS_MESSAGES: Record<string, string> = {
};
export default async function OrdersPage({ searchParams }: { searchParams: { archived?: string; personalised?: string; success?: string } }) {
// Handle success messages from other pages
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;
await autoArchiveOldOrders();
const success = searchParams.success ? SUCCESS_MESSAGES[searchParams.success] : null;