c8f4b50ef3
All literal "team" tier-value references renamed to "family" across API routes, admin UI, OpenAPI schemas, and lib/tiers.ts. The DB enum value itself is renamed in place via ALTER TYPE ... RENAME VALUE (migration 0044) rather than drizzle-kit's auto-generated drop-and-recreate-the-enum migration, which would have failed against any existing row still holding 'team' — RENAME VALUE preserves existing data with no cast/backfill needed. Also adds STRIPE_PLAN.md — a full Stripe billing integration plan (Checkout+Portal, tier→Price mapping, admin billing dashboard, and a multi-user Family-group design since Family is meant to cover several accounts under one subscription, not one payer). Planning only, no Stripe code yet. v0.47.0
173 lines
6.0 KiB
TypeScript
173 lines
6.0 KiB
TypeScript
import type { Metadata } from "next";
|
|
import { notFound } from "next/navigation";
|
|
import { db, users, recipes, userUsage, eq, desc, count, and, sql } from "@epicure/db";
|
|
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Separator } from "@/components/ui/separator";
|
|
import { UserEditor } from "@/components/admin/user-editor";
|
|
import { ResetUsageButton } from "@/components/admin/reset-usage-button";
|
|
import Link from "next/link";
|
|
import { ArrowLeft } from "lucide-react";
|
|
|
|
export const metadata: Metadata = {};
|
|
|
|
interface PageProps {
|
|
params: Promise<{ id: string }>;
|
|
}
|
|
|
|
const ROLE_COLORS = {
|
|
user: "secondary",
|
|
moderator: "outline",
|
|
admin: "destructive",
|
|
} as const;
|
|
|
|
const TIER_COLORS = {
|
|
free: "secondary",
|
|
pro: "default",
|
|
family: "default",
|
|
} as const;
|
|
|
|
export default async function AdminUserDetailPage({ params }: PageProps) {
|
|
const { id } = await params;
|
|
|
|
const [user] = await db.select().from(users).where(eq(users.id, id)).limit(1);
|
|
if (!user) notFound();
|
|
|
|
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
|
|
const [usage] = await db
|
|
.select()
|
|
.from(userUsage)
|
|
.where(and(eq(userUsage.userId, id), eq(userUsage.month, currentMonth)))
|
|
.limit(1);
|
|
|
|
const [recipeCountRow] = await db
|
|
.select({ count: count() })
|
|
.from(recipes)
|
|
.where(eq(recipes.authorId, id));
|
|
|
|
const recentRecipes = await db
|
|
.select({
|
|
id: recipes.id,
|
|
title: recipes.title,
|
|
visibility: recipes.visibility,
|
|
createdAt: recipes.createdAt,
|
|
})
|
|
.from(recipes)
|
|
.where(eq(recipes.authorId, id))
|
|
.orderBy(desc(recipes.createdAt))
|
|
.limit(10);
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center gap-3">
|
|
<Link
|
|
href="/admin/users"
|
|
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ArrowLeft className="h-4 w-4" />
|
|
Back to Users
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-4">
|
|
<Avatar className="h-16 w-16">
|
|
<AvatarImage src={user.avatarUrl ?? ""} alt={user.name} />
|
|
<AvatarFallback className="text-lg">
|
|
{user.name.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight">{user.name}</h1>
|
|
<p className="text-muted-foreground">{user.email}</p>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
Joined {user.createdAt.toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
<div className="ml-auto flex gap-2">
|
|
<Badge variant={ROLE_COLORS[user.role]}>{user.role}</Badge>
|
|
<Badge variant={TIER_COLORS[user.tier]}>{user.tier}</Badge>
|
|
</div>
|
|
</div>
|
|
|
|
<Separator />
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Edit Role & Tier</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<UserEditor userId={user.id} currentRole={user.role} currentTier={user.tier} />
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">Usage This Month ({currentMonth})</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">AI Calls Used</span>
|
|
<span className="font-medium">{usage?.aiCallsUsed ?? 0}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Total Recipes</span>
|
|
<span className="font-medium">{recipeCountRow?.count ?? 0}</span>
|
|
</div>
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">Storage Used</span>
|
|
<span className="font-medium">{usage?.storageUsedMb ?? 0} MB</span>
|
|
</div>
|
|
<div className="pt-2">
|
|
<ResetUsageButton userId={user.id} />
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
<div>
|
|
<h2 className="text-lg font-semibold mb-3">Recent Recipes</h2>
|
|
{recentRecipes.length === 0 ? (
|
|
<p className="text-muted-foreground text-sm">No recipes yet.</p>
|
|
) : (
|
|
<div className="rounded-md border">
|
|
<table className="w-full text-sm">
|
|
<thead className="border-b bg-muted/50">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left font-medium">Title</th>
|
|
<th className="px-4 py-3 text-left font-medium">Visibility</th>
|
|
<th className="px-4 py-3 text-left font-medium">Created</th>
|
|
<th className="px-4 py-3 text-left font-medium">Link</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{recentRecipes.map((recipe) => (
|
|
<tr key={recipe.id} className="border-b last:border-0 hover:bg-muted/30">
|
|
<td className="px-4 py-3 font-medium">{recipe.title}</td>
|
|
<td className="px-4 py-3">
|
|
<Badge variant="outline">{recipe.visibility}</Badge>
|
|
</td>
|
|
<td className="px-4 py-3 text-muted-foreground">
|
|
{recipe.createdAt.toLocaleDateString()}
|
|
</td>
|
|
<td className="px-4 py-3">
|
|
<Link
|
|
href={`/r/${recipe.id}`}
|
|
className="text-primary underline-offset-4 hover:underline text-xs"
|
|
>
|
|
View
|
|
</Link>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|