445 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|