letsbe-hub/src/app/admin/customers/page.tsx

445 lines
15 KiB
TypeScript

'use client'
import { useState, useMemo } from 'react'
import Link from 'next/link'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { useCustomers } from '@/hooks/use-customers'
import { UserStatus as ApiUserStatus } from '@/types/api'
import {
Search,
Plus,
MoreHorizontal,
User,
Mail,
Building2,
Calendar,
Server,
ExternalLink,
ChevronLeft,
ChevronRight,
Loader2,
AlertCircle,
RefreshCw,
} from 'lucide-react'
type UserStatus = 'ACTIVE' | 'SUSPENDED' | 'PENDING_VERIFICATION'
type SubscriptionStatus = 'TRIAL' | 'ACTIVE' | 'CANCELED' | 'PAST_DUE'
interface Customer {
id: string
name: string
email: string
company: string | null
status: UserStatus
subscription: {
plan: string
tier: string
status: SubscriptionStatus
tokensUsed: number
tokenLimit: number
} | null
activeServers: number
createdAt: string
}
// Status badge component
function UserStatusBadge({ status }: { status: UserStatus }) {
const statusConfig: Record<UserStatus, { label: string; className: string }> = {
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
SUSPENDED: { label: 'Suspended', className: 'bg-red-100 text-red-800' },
PENDING_VERIFICATION: { label: 'Pending', className: 'bg-yellow-100 text-yellow-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
{config.label}
</span>
)
}
function SubscriptionBadge({ status }: { status: SubscriptionStatus }) {
const statusConfig: Record<SubscriptionStatus, { label: string; className: string }> = {
TRIAL: { label: 'Trial', className: 'bg-blue-100 text-blue-800' },
ACTIVE: { label: 'Active', className: 'bg-green-100 text-green-800' },
CANCELED: { label: 'Canceled', className: 'bg-gray-100 text-gray-800' },
PAST_DUE: { label: 'Past Due', className: 'bg-red-100 text-red-800' },
}
const config = statusConfig[status]
return (
<span className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${config.className}`}>
{config.label}
</span>
)
}
// Customer table row component
function CustomerRow({ customer }: { customer: Customer }) {
return (
<tr className="border-b hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10">
<User className="h-5 w-5 text-primary" />
</div>
<div>
<Link
href={`/admin/customers/${customer.id}`}
className="font-medium hover:underline"
>
{customer.name}
</Link>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Mail className="h-3 w-3" />
{customer.email}
</div>
</div>
</div>
</td>
<td className="px-4 py-3">
{customer.company ? (
<div className="flex items-center gap-2">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{customer.company}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-4 py-3">
<UserStatusBadge status={customer.status} />
</td>
<td className="px-4 py-3">
{customer.subscription ? (
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="font-medium capitalize">{customer.subscription.plan.toLowerCase()}</span>
<SubscriptionBadge status={customer.subscription.status} />
</div>
<span className="text-xs text-muted-foreground capitalize">
{customer.subscription.tier.replace('_', ' ').toLowerCase()}
</span>
</div>
) : (
<span className="text-muted-foreground">No subscription</span>
)}
</td>
<td className="px-4 py-3">
{customer.subscription ? (
<div className="space-y-1">
<div className="text-sm">
{customer.subscription.tokensUsed.toLocaleString()} /{' '}
{customer.subscription.tokenLimit.toLocaleString()}
</div>
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-gray-200">
<div
className="h-full bg-primary"
style={{
width: `${Math.min(
(customer.subscription.tokensUsed / customer.subscription.tokenLimit) * 100,
100
)}%`,
}}
/>
</div>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-muted-foreground" />
<span>{customer.activeServers}</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
{new Date(customer.createdAt).toLocaleDateString()}
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Link href={`/admin/customers/${customer.id}`}>
<Button variant="ghost" size="icon">
<ExternalLink className="h-4 w-4" />
</Button>
</Link>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
)
}
export default function CustomersPage() {
const [search, setSearch] = useState('')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [currentPage, setCurrentPage] = useState(1)
const itemsPerPage = 10
// Fetch customers from API
const {
data,
isLoading,
isError,
error,
refetch,
isFetching,
} = useCustomers({
search: search || undefined,
status: statusFilter !== 'all' ? statusFilter as ApiUserStatus : undefined,
page: currentPage,
limit: itemsPerPage,
})
// Map API customers to component format
const customers = useMemo<Customer[]>(() => {
if (!data?.customers) return []
return data.customers.map((c) => ({
id: c.id,
name: c.name || c.email,
email: c.email,
company: c.company,
status: c.status as UserStatus,
subscription: c.subscriptions?.[0] ? {
plan: c.subscriptions[0].plan,
tier: c.subscriptions[0].tier,
status: c.subscriptions[0].status as SubscriptionStatus,
tokensUsed: 0, // Not included in list response
tokenLimit: 0, // Not included in list response
} : null,
activeServers: c._count?.orders || 0,
createdAt: String(c.createdAt),
}))
}, [data?.customers])
// Calculate stats from API data
const stats = useMemo(() => ({
total: data?.pagination?.total || 0,
active: customers.filter((c) => c.status === 'ACTIVE').length,
trial: customers.filter((c) => c.subscription?.status === 'TRIAL').length,
totalServers: customers.reduce((acc, c) => acc + c.activeServers, 0),
}), [customers, data?.pagination?.total])
const totalPages = data?.pagination?.totalPages || 1
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-muted-foreground">Loading customers...</p>
</div>
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex items-center justify-center h-[50vh]">
<div className="flex flex-col items-center gap-4 text-center">
<AlertCircle className="h-8 w-8 text-destructive" />
<div>
<p className="font-medium text-destructive">Failed to load customers</p>
<p className="text-sm text-muted-foreground">
{error instanceof Error ? error.message : 'An error occurred'}
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Page header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Customers</h1>
<p className="text-muted-foreground">
Manage customer accounts and subscriptions
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isFetching ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button>
<Plus className="mr-2 h-4 w-4" />
Add Customer
</Button>
</div>
</div>
{/* Stats cards */}
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.total}</div>
<p className="text-sm text-muted-foreground">Total Customers</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.active}</div>
<p className="text-sm text-muted-foreground">Active</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.trial}</div>
<p className="text-sm text-muted-foreground">On Trial</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-6">
<div className="text-2xl font-bold">{stats.totalServers}</div>
<p className="text-sm text-muted-foreground">Total Servers</p>
</CardContent>
</Card>
</div>
{/* Filters and table */}
<Card>
<CardHeader>
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>All Customers</CardTitle>
<CardDescription>
{data?.pagination?.total || 0} customer{(data?.pagination?.total || 0) !== 1 ? 's' : ''} found
</CardDescription>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
type="search"
placeholder="Search customers..."
value={search}
onChange={(e) => {
setSearch(e.target.value)
setCurrentPage(1) // Reset to first page on search
}}
className="pl-9 w-full sm:w-64"
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value)
setCurrentPage(1) // Reset to first page on filter change
}}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
>
<option value="all">All Status</option>
<option value="ACTIVE">Active</option>
<option value="SUSPENDED">Suspended</option>
<option value="PENDING_VERIFICATION">Pending</option>
</select>
</div>
</div>
</CardHeader>
<CardContent>
{customers.length === 0 ? (
<div className="text-center py-8">
<p className="text-muted-foreground">No customers found</p>
{(search || statusFilter !== 'all') && (
<Button
variant="link"
onClick={() => {
setSearch('')
setStatusFilter('all')
setCurrentPage(1)
}}
>
Clear filters
</Button>
)}
</div>
) : (
<>
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b text-left text-sm text-muted-foreground">
<th className="px-4 py-3 font-medium">Customer</th>
<th className="px-4 py-3 font-medium">Company</th>
<th className="px-4 py-3 font-medium">Status</th>
<th className="px-4 py-3 font-medium">Subscription</th>
<th className="px-4 py-3 font-medium">Token Usage</th>
<th className="px-4 py-3 font-medium">Servers</th>
<th className="px-4 py-3 font-medium">Joined</th>
<th className="px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody>
{customers.map((customer) => (
<CustomerRow key={customer.id} customer={customer} />
))}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between border-t pt-4">
<p className="text-sm text-muted-foreground">
Page {currentPage} of {totalPages}
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="h-4 w-4" />
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
)
}