feat: add admin operations suite
Local Build and Deploy / deploy (push) Successful in 50s

This commit is contained in:
Simo committed 2026-07-12 20:11:28 +02:00
1 parent 21a61068fb
commit 60278b9e71
38 files changed
+3452

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface ChartDataPoint { label: string; value: number }
export function DashboardChart({ title, data, description }: { title: string; data: ChartDataPoint[]; color?: string; description?: string }) {
const max = Math.max(1, ...data.map((point) => point.value))
return (
<Card>
<CardHeader className="pb-2"><CardTitle className="text-sm font-medium">{title}</CardTitle>{description && <p className="text-xs text-muted-foreground">{description}</p>}</CardHeader>
<CardContent><div className="flex h-[200px] items-end gap-1" aria-label={title}>{data.map((point) => <div key={point.label} className="flex min-w-0 flex-1 flex-col items-center gap-1"><span className="text-[10px] text-muted-foreground">{point.value}</span><div className="w-full rounded-t bg-[var(--admin-accent)]" style={{ height: `${Math.max(2, (point.value / max) * 150)}px` }} title={`${point.label}: ${point.value}`} /><span className="max-w-full truncate text-[9px] text-muted-foreground">{point.label}</span></div>)}</div></CardContent>
</Card>
)
}
@@ -0,0 +1,84 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
interface PeakHoursData {
/** [dayOfWeek (0=Mon..6=Sun)][hour (0..23)] = count */
grid: number[][]
max: number
}
const DAYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
const HOURS = Array.from({ length: 24 }, (_, i) => i)
function getColor(value: number, max: number) {
if (max === 0 || value === 0) return 'bg-muted'
const ratio = value / max
if (ratio < 0.25) return 'bg-[var(--admin-success)]/20'
if (ratio < 0.5) return 'bg-[var(--admin-success)]/55'
if (ratio < 0.75) return 'bg-[var(--admin-success)] dark:bg-[var(--admin-success)]'
return 'bg-[var(--admin-success-strong,var(--admin-success))]'
}
export function PeakHoursHeatmap({ data }: { data: PeakHoursData }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Peak Hours</CardTitle>
<p className="text-xs text-muted-foreground">
User activity by day &amp; hour (last 7 days)
</p>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<div className="min-w-[500px]">
{/* Hour labels */}
<div className="flex gap-px ml-10 mb-1">
{HOURS.map((h) => (
<div key={h} className="flex-1 text-center text-[10px] text-muted-foreground">
{h % 3 === 0 ? `${h}` : ''}
</div>
))}
</div>
{/* Rows */}
{DAYS.map((day, dayIdx) => (
<div key={day} className="flex items-center gap-px mb-px">
<span className="w-10 text-xs text-muted-foreground text-right pr-2 shrink-0">
{day}
</span>
<TooltipProvider delayDuration={0}>
{HOURS.map((hour) => {
const count = data.grid[dayIdx]?.[hour] ?? 0
return (
<Tooltip key={hour}>
<TooltipTrigger asChild>
<div
className={`flex-1 aspect-square rounded-sm ${getColor(count, data.max)} transition-colors min-w-[14px]`}
/>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs">
{day} {hour}:00 — {count} users
</TooltipContent>
</Tooltip>
)
})}
</TooltipProvider>
</div>
))}
{/* Legend */}
<div className="flex items-center gap-2 mt-3 ml-10">
<span className="text-[10px] text-muted-foreground">Less</span>
<div className="h-3 w-3 rounded-sm bg-muted" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/20" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)]/55" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success)] dark:bg-[var(--admin-success)]" />
<div className="h-3 w-3 rounded-sm bg-[var(--admin-success-strong,var(--admin-success))]" />
<span className="text-[10px] text-muted-foreground">More</span>
</div>
</div>
</div>
</CardContent>
</Card>
)
}
+10
View File
@@ -0,0 +1,10 @@
'use client'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface RevenueDataPoint { label: string; credits: number; points: number }
export function RevenueChart({ data }: { data: RevenueDataPoint[] }) {
const max = Math.max(1, ...data.flatMap((point) => [point.credits, point.points]))
return <Card><CardHeader className="pb-2"><CardTitle className="text-sm font-medium">Catalog Spending</CardTitle></CardHeader><CardContent><div className="space-y-3">{data.map((point) => <div key={point.label} className="grid grid-cols-[3rem_1fr] items-center gap-2 text-xs"><span>{point.label}</span><div className="space-y-1"><div className="h-2 rounded bg-[var(--admin-warning)]" style={{ width: `${Math.max(2, point.credits / max * 100)}%` }} title={`${point.credits} credits`} /><div className="h-2 rounded bg-[var(--admin-accent)]" style={{ width: `${Math.max(2, point.points / max * 100)}%` }} title={`${point.points} points`} /></div></div>)}</div></CardContent></Card>
}
+67
View File
@@ -0,0 +1,67 @@
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
import Link from 'next/link'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
interface StatsCardProps {
title: string
value: string | number
description?: string
icon?: React.ReactNode
href?: string
/** Percentage change vs previous period. Positive = up, negative = down, undefined = no trend */
trend?: number
}
export function StatsCard({ title, value, description, icon, href, trend }: StatsCardProps) {
const content = (
<Card
className={
href
? 'transition-all duration-200 hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5 cursor-pointer'
: ''
}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
{icon && <div className="text-muted-foreground">{icon}</div>}
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-2">
<div className="text-2xl font-bold">{value}</div>
{trend !== undefined && <TrendBadge value={trend} />}
</div>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
</CardContent>
</Card>
)
if (href) {
return <Link href={href}>{content}</Link>
}
return content
}
function TrendBadge({ value }: { value: number }) {
if (value === 0) {
return (
<span className="inline-flex items-center gap-0.5 text-xs text-muted-foreground">
<Minus className="h-3 w-3" />
0%
</span>
)
}
const isUp = value > 0
return (
<span
className={`inline-flex items-center gap-0.5 text-xs font-medium ${
isUp ? 'text-[var(--admin-success)]' : 'text-[var(--admin-error)]'
}`}
>
{isUp ? <TrendingUp className="h-3 w-3" /> : <TrendingDown className="h-3 w-3" />}
{isUp ? '+' : ''}
{value}%
</span>
)
}