feat: add admin operations suite

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

+149
View File
@@ -0,0 +1,149 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { AvatarImage } from '@/components/shared/avatar-image'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent } from '@/components/ui/card'
import { canAccess, getAdminContext, PERMS } from '@/lib/permissions'
import { prisma } from '@/lib/prisma'
export default async function ModTeamPage() {
const { session, permissions } = await getAdminContext()
if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) redirect('/admin')
// Get all staff (rank >= 4)
const staff = await prisma.user.findMany({
where: { rank: { gte: 4 } },
select: {
id: true,
username: true,
look: true,
rank: true,
online: true,
lastLogin: true,
lastOnline: true,
},
orderBy: [{ online: 'desc' }, { rank: 'desc' }],
})
// Get CFH ticket counts per mod and web ticket counts
const [cfhCounts, webTicketCounts, modActionCounts] = await Promise.all([
prisma.supportTickets.groupBy({
by: ['modId'],
where: { modId: { in: staff.map((s) => s.id) } },
_count: { _all: true },
}),
prisma.websiteTicket.groupBy({
by: ['assigneeId'],
where: { assigneeId: { in: staff.map((s) => s.id) } },
_count: { _all: true },
}),
prisma.adminAuditLog.groupBy({
by: ['userId'],
where: {
userId: { in: staff.map((s) => s.id) },
action: { startsWith: 'mod_' },
},
_count: { _all: true },
}),
])
const cfhMap = new Map(cfhCounts.map((c) => [c.modId, c._count._all]))
const webMap = new Map(webTicketCounts.map((c) => [c.assigneeId, c._count._all]))
const actionMap = new Map(modActionCounts.map((c) => [c.userId, c._count._all]))
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Mod Team</h1>
<p className="text-muted-foreground">Staff performance and status</p>
</div>
{/* Summary */}
<div className="grid gap-4 grid-cols-3">
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-[var(--admin-success)]">
{staff.filter((s) => s.online === '1').length}
</p>
<p className="text-xs text-muted-foreground">Online Now</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold">{staff.length}</p>
<p className="text-xs text-muted-foreground">Total Staff</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4 text-center">
<p className="text-2xl font-bold text-primary">
{staff.filter((s) => s.rank >= 7).length}
</p>
<p className="text-xs text-muted-foreground">Super Admins</p>
</CardContent>
</Card>
</div>
{/* Staff list */}
<div className="rounded-lg border">
<div className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 bg-muted/50 text-xs font-medium text-muted-foreground border-b">
<span></span>
<span>Name</span>
<span>Rank</span>
<span>CFH</span>
<span>Tickets</span>
<span>Actions</span>
<span>Last Seen</span>
</div>
{staff.map((member) => (
<Link
key={member.id}
href={`/admin/users/show/${member.id}`}
className="grid grid-cols-[auto_1fr_auto_auto_auto_auto_auto] gap-4 p-3 items-center hover:bg-accent/50 transition-all duration-150 border-b last:border-0"
>
<div className="relative">
<AvatarImage
figure={member.look}
options={{ headOnly: true, size: 'l' }}
alt={member.username}
width={32}
height={32}
/>
<span
className={`absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full border-2 border-background ${
member.online === '1' ? 'bg-[var(--admin-success)]' : 'bg-[var(--admin-text-muted)]'
}`}
/>
</div>
<div>
<p className="text-sm font-medium">{member.username}</p>
</div>
<Badge variant="outline" className="text-[10px]">
Rank {member.rank}
</Badge>
<div className="text-center">
<p className="text-sm font-bold">{cfhMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">CFH</p>
</div>
<div className="text-center">
<p className="text-sm font-bold">{webMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">Web</p>
</div>
<div className="text-center">
<p className="text-sm font-bold">{actionMap.get(member.id) ?? 0}</p>
<p className="text-[9px] text-muted-foreground">Mod</p>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap">
{member.online === '1'
? 'Online'
: member.lastOnline
? new Date(member.lastOnline * 1000).toLocaleDateString()
: '—'}
</span>
</Link>
))}
</div>
</div>
)
}