feat(mod): tickets+team tabs; retire HK writes for live ACL
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
084cca6ea4
commit
75cace41ea
16 files changed
+735
-1002
No files matched your search
+14
-1
@@ -1,4 +1,4 @@
|
||||
import { Ban, Gavel, LogOut, Radio, Shield } from "lucide-react";
|
||||
import { Ban, Gavel, LogOut, Radio, Shield, Ticket, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
@@ -42,11 +42,23 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
const showBans =
|
||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, staff.rank) ||
|
||||
canAccess(permissions, PERMS.BANS_VIEW, staff.rank);
|
||||
const showTickets =
|
||||
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, staff.rank) ||
|
||||
canAccess(permissions, PERMS.TICKETS_VIEW, staff.rank);
|
||||
const showTeam =
|
||||
canAccess(permissions, PERMS.MOD_TEAM_VIEW, staff.rank) ||
|
||||
canAccess(permissions, PERMS.MODERATION_VIEW, staff.rank);
|
||||
const showAdmin = canAccess(permissions, PERMS.ADMIN_DASHBOARD, staff.rank);
|
||||
|
||||
const nav = [
|
||||
{ href: "/mod", label: t("nav.overview"), icon: Shield, show: true },
|
||||
{ href: "/mod/cfh", label: t("nav.cfh"), icon: Radio, show: showCfh },
|
||||
{
|
||||
href: "/mod/tickets",
|
||||
label: t("nav.tickets"),
|
||||
icon: Ticket,
|
||||
show: showTickets,
|
||||
},
|
||||
{
|
||||
href: "/mod/actions",
|
||||
label: t("nav.actions"),
|
||||
@@ -54,6 +66,7 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
show: showActions,
|
||||
},
|
||||
{ href: "/mod/bans", label: t("nav.bans"), icon: Ban, show: showBans },
|
||||
{ href: "/mod/team", label: t("nav.team"), icon: Users, show: showTeam },
|
||||
].filter((item) => item.show);
|
||||
|
||||
return (
|
||||
|
||||
+49
-3
@@ -1,4 +1,4 @@
|
||||
import { Ban, Gavel, Radio, Shield } from "lucide-react";
|
||||
import { Ban, Gavel, Radio, Shield, Ticket, Users } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatsCard } from "@/components/admin/stats-card";
|
||||
@@ -23,8 +23,14 @@ export default async function ModDashboardPage() {
|
||||
const showBans =
|
||||
canAccess(permissions, PERMS.MOD_BANS_VIEW, rank) ||
|
||||
canAccess(permissions, PERMS.BANS_VIEW, rank);
|
||||
const showTickets =
|
||||
canAccess(permissions, PERMS.MOD_TICKETS_VIEW, rank) ||
|
||||
canAccess(permissions, PERMS.TICKETS_VIEW, rank);
|
||||
const showTeam =
|
||||
canAccess(permissions, PERMS.MOD_TEAM_VIEW, rank) ||
|
||||
canAccess(permissions, PERMS.MODERATION_VIEW, rank);
|
||||
|
||||
const [openCfh, todayBans, modsOnline] = await Promise.all([
|
||||
const [openCfh, todayBans, modsOnline, openTickets] = await Promise.all([
|
||||
showCfh
|
||||
? prisma.supportTickets.count({ where: { state: 0 } }).catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
@@ -36,6 +42,11 @@ export default async function ModDashboardPage() {
|
||||
prisma.user
|
||||
.count({ where: { online: "1", rank: { gte: minStaffRank } } })
|
||||
.catch(() => 0),
|
||||
showTickets
|
||||
? prisma.websiteTicket
|
||||
.count({ where: { status: { not: "closed" } } })
|
||||
.catch(() => 0)
|
||||
: Promise.resolve(0),
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -47,7 +58,7 @@ export default async function ModDashboardPage() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{showCfh ? (
|
||||
<StatsCard
|
||||
title={t("stats.openCfh")}
|
||||
@@ -56,6 +67,14 @@ export default async function ModDashboardPage() {
|
||||
href="/mod/cfh"
|
||||
/>
|
||||
) : null}
|
||||
{showTickets ? (
|
||||
<StatsCard
|
||||
title={t("stats.openTickets")}
|
||||
value={openTickets}
|
||||
icon={<Ticket className="h-4 w-4" />}
|
||||
href="/mod/tickets"
|
||||
/>
|
||||
) : null}
|
||||
{showBans ? (
|
||||
<StatsCard
|
||||
title={t("stats.bansToday")}
|
||||
@@ -68,6 +87,7 @@ export default async function ModDashboardPage() {
|
||||
title={t("stats.modsOnline")}
|
||||
value={modsOnline}
|
||||
icon={<Shield className="h-4 w-4" />}
|
||||
href={showTeam ? "/mod/team" : undefined}
|
||||
/>
|
||||
{showActions ? (
|
||||
<StatsCard
|
||||
@@ -78,6 +98,15 @@ export default async function ModDashboardPage() {
|
||||
href="/mod/actions"
|
||||
/>
|
||||
) : null}
|
||||
{showTeam ? (
|
||||
<StatsCard
|
||||
title={t("stats.team")}
|
||||
value="→"
|
||||
description={t("stats.teamHint")}
|
||||
icon={<Users className="h-4 w-4" />}
|
||||
href="/mod/team"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<section className="admin-card space-y-2 p-4">
|
||||
@@ -90,6 +119,16 @@ export default async function ModDashboardPage() {
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showTickets ? (
|
||||
<li>
|
||||
<Link
|
||||
href="/mod/tickets"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
{t("nav.tickets")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showActions ? (
|
||||
<li>
|
||||
<Link
|
||||
@@ -107,6 +146,13 @@ export default async function ModDashboardPage() {
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
{showTeam ? (
|
||||
<li>
|
||||
<Link href="/mod/team" className="text-primary hover:underline">
|
||||
{t("nav.team")}
|
||||
</Link>
|
||||
</li>
|
||||
) : null}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import Link from "next/link";
|
||||
import { AvatarImage } from "@/components/shared/avatar-image";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function ModTeamLitePage() {
|
||||
await requireModPermission([PERMS.MOD_TEAM_VIEW, PERMS.MODERATION_VIEW]);
|
||||
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
const staff = await prisma.user.findMany({
|
||||
where: { rank: { gte: minStaffRank } },
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
look: true,
|
||||
rank: true,
|
||||
online: true,
|
||||
lastLogin: true,
|
||||
lastOnline: true,
|
||||
},
|
||||
orderBy: [{ online: "desc" }, { rank: "desc" }],
|
||||
});
|
||||
|
||||
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 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 > minStaffRank).length}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">Above min rank</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<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>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={`/u/${encodeURIComponent(member.username)}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { AdminTicketDetail } from "@/app/admin/tickets/[id]/admin-ticket-detail";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { getMinStaffRank } from "@/lib/admin/min-staff-rank";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export default async function ModTicketDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||
|
||||
const { session, permissions } = await getAdminContext();
|
||||
const { id } = await params;
|
||||
const ticketId = Number(id);
|
||||
if (Number.isNaN(ticketId)) notFound();
|
||||
|
||||
const ticket = await prisma.websiteTicket.findUnique({
|
||||
where: { id: ticketId },
|
||||
include: {
|
||||
creator: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
look: true,
|
||||
rank: true,
|
||||
ipRegister: true,
|
||||
mail: true,
|
||||
accountCreated: true,
|
||||
},
|
||||
},
|
||||
assignee: { select: { id: true, username: true } },
|
||||
messages: {
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: {
|
||||
user: {
|
||||
select: { id: true, username: true, look: true, rank: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) notFound();
|
||||
|
||||
const minStaffRank = await getMinStaffRank();
|
||||
const staffMembers = await prisma.user.findMany({
|
||||
where: { rank: { gte: minStaffRank } },
|
||||
select: { id: true, username: true },
|
||||
orderBy: { username: "asc" },
|
||||
});
|
||||
|
||||
const canEdit =
|
||||
canAccess(permissions, PERMS.MOD_TICKETS_EDIT, session.user.rank) ||
|
||||
canAccess(permissions, PERMS.TICKETS_EDIT, session.user.rank);
|
||||
|
||||
return (
|
||||
<AdminTicketDetail
|
||||
ticket={{
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
category: ticket.category,
|
||||
priority: ticket.priority,
|
||||
status: ticket.status,
|
||||
createdAt: ticket.createdAt.toISOString(),
|
||||
closedAt: ticket.closedAt?.toISOString() ?? null,
|
||||
creator: {
|
||||
id: ticket.creator.id,
|
||||
username: ticket.creator.username,
|
||||
look: ticket.creator.look,
|
||||
rank: ticket.creator.rank,
|
||||
mail: ticket.creator.mail ?? "",
|
||||
ipRegister: ticket.creator.ipRegister,
|
||||
accountCreated: ticket.creator.accountCreated,
|
||||
},
|
||||
assignee: ticket.assignee
|
||||
? { id: ticket.assignee.id, username: ticket.assignee.username }
|
||||
: null,
|
||||
}}
|
||||
messages={ticket.messages.map((m) => ({
|
||||
id: m.id,
|
||||
message: m.message,
|
||||
isStaff: m.isStaff === 1,
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
user: {
|
||||
id: m.user.id,
|
||||
username: m.user.username,
|
||||
look: m.user.look,
|
||||
rank: m.user.rank,
|
||||
},
|
||||
}))}
|
||||
staffMembers={staffMembers}
|
||||
currentUserId={session.user.id}
|
||||
canEdit={canEdit}
|
||||
listHref="/mod/tickets"
|
||||
userLinkMode="public"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
type TicketRow,
|
||||
TicketsTable,
|
||||
} from "@/app/admin/tickets/tickets-table";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function fromDate(d: Date | null | undefined): string {
|
||||
return d ? d.toISOString().slice(0, 10) : "—";
|
||||
}
|
||||
|
||||
export default async function ModTicketsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||
|
||||
const t = await getTranslations("pages.admin.tickets");
|
||||
const raw = await searchParams;
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || raw.status || "active";
|
||||
|
||||
const conditions: Prisma.WebsiteTicketWhereInput[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push({ status: { not: "closed" } });
|
||||
} else if (statusFilter && statusFilter !== "all") {
|
||||
conditions.push({ status: statusFilter });
|
||||
}
|
||||
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
const asId = Number(q);
|
||||
conditions.push({
|
||||
OR: [
|
||||
{ subject: { contains: q } },
|
||||
{ category: { contains: q } },
|
||||
{ creator: { username: { contains: q } } },
|
||||
{ assignee: { username: { contains: q } } },
|
||||
...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const where: Prisma.WebsiteTicketWhereInput =
|
||||
conditions.length === 0
|
||||
? {}
|
||||
: conditions.length === 1
|
||||
? conditions[0]
|
||||
: { AND: conditions };
|
||||
|
||||
const SORT_MAP: Record<string, Prisma.WebsiteTicketOrderByWithRelationInput> =
|
||||
{
|
||||
subject: { subject: "asc" },
|
||||
status: { status: "asc" },
|
||||
priority: { priority: "asc" },
|
||||
assignee: { assigneeId: "asc" },
|
||||
date: { createdAt: "desc" },
|
||||
id: { id: "desc" },
|
||||
};
|
||||
|
||||
const baseOrder = SORT_MAP[parsed.sort ?? "date"] ?? { createdAt: "desc" };
|
||||
const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
|
||||
const orderBy = {
|
||||
[orderField]: parsed.order,
|
||||
} as Prisma.WebsiteTicketOrderByWithRelationInput;
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount] =
|
||||
await Promise.all([
|
||||
prisma.websiteTicket.count({ where }).catch(() => 0),
|
||||
prisma.websiteTicket.count({ where: { status: "open" } }).catch(() => 0),
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "in_progress" } })
|
||||
.catch(() => 0),
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "waiting" } })
|
||||
.catch(() => 0),
|
||||
prisma.websiteTicket
|
||||
.count({ where: { status: "closed" } })
|
||||
.catch(() => 0),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await prisma.websiteTicket
|
||||
.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
skip: pagination.offset,
|
||||
take: pagination.perPage,
|
||||
include: {
|
||||
creator: { select: { id: true, username: true } },
|
||||
assignee: { select: { id: true, username: true } },
|
||||
_count: { select: { messages: true } },
|
||||
},
|
||||
})
|
||||
.catch(() => []);
|
||||
|
||||
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
creator: ticket.creator.username,
|
||||
creatorId: ticket.creatorId,
|
||||
category: ticket.category,
|
||||
messages: ticket._count.messages,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assignee?.username ?? "—",
|
||||
date: fromDate(ticket.createdAt),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard
|
||||
label={t("statusInProgress")}
|
||||
value={inProgressCount}
|
||||
state={inProgressCount > 0 ? "warn" : "neutral"}
|
||||
icon="⏳"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("statusWaiting")}
|
||||
value={waitingCount}
|
||||
state={waitingCount > 0 ? "warn" : "neutral"}
|
||||
icon="🕒"
|
||||
/>
|
||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||
</div>
|
||||
|
||||
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
||||
<div className="admin-empty">{t("noTickets")}</div>
|
||||
) : (
|
||||
<TicketsTable
|
||||
basePath="/mod/tickets"
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user