diff --git a/src/app/admin/moderation/actions/mod-actions-client.tsx b/src/app/admin/moderation/actions/mod-actions-client.tsx
index 27ae40b5..323425a4 100644
--- a/src/app/admin/moderation/actions/mod-actions-client.tsx
+++ b/src/app/admin/moderation/actions/mod-actions-client.tsx
@@ -19,6 +19,7 @@ import {
quickRoomKick,
quickUnmute,
} from "@/actions/moderation";
+import { useConfirmDialog } from "@/components/admin/confirm-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
@@ -27,24 +28,65 @@ import { useServerAction } from "@/hooks/use-server-action";
export function ModActionsClient() {
const { run, isPending } = useServerAction();
+ const { confirm, dialog: confirmDialog } = useConfirmDialog();
- // User actions
const [userId, setUserId] = useState("");
const [muteDuration, setMuteDuration] = useState("10");
const [alertMessage, setAlertMessage] = useState("");
-
- // Room actions
const [roomId, setRoomId] = useState("");
-
- // Broadcast
const [broadcastMsg, setBroadcastMsg] = useState("");
const uid = parseInt(userId, 10);
const rid = parseInt(roomId, 10);
+ async function handleKick() {
+ const ok = await confirm({
+ title: "Kick user",
+ description: `Disconnect user #${uid}?`,
+ confirmLabel: "Kick",
+ });
+ if (!ok) return;
+ run(() => quickKick({ userId: uid }), {
+ successMessage: "User disconnected",
+ });
+ }
+
+ async function handleRoomKick() {
+ const ok = await confirm({
+ title: "Kick all from room",
+ description: `Kick every user from room #${rid}?`,
+ confirmLabel: "Kick all",
+ });
+ if (!ok) return;
+ run(() => quickRoomKick({ roomId: rid }), {
+ successMessage: "All users kicked from room",
+ });
+ }
+
+ async function handleHotelBroadcast() {
+ const ok = await confirm({
+ title: "Hotel-wide alert",
+ description: "Send this alert to everyone in the hotel?",
+ confirmLabel: "Send",
+ variant: "default",
+ });
+ if (!ok) return;
+ run(
+ () =>
+ broadcastAlert({
+ message: broadcastMsg.trim(),
+ type: "hotel",
+ }),
+ {
+ successMessage: "Hotel alert sent!",
+ onSuccess: () => setBroadcastMsg(""),
+ },
+ );
+ }
+
return (
- {/* User Actions */}
+ {confirmDialog}
@@ -70,11 +112,7 @@ export function ModActionsClient() {
variant="destructive"
size="sm"
disabled={isPending || !uid}
- onClick={() =>
- run(() => quickKick({ userId: uid }), {
- successMessage: "User disconnected",
- })
- }
+ onClick={handleKick}
>
{isPending ? (
@@ -159,7 +197,6 @@ export function ModActionsClient() {
- {/* Room Actions */}
@@ -185,18 +222,13 @@ export function ModActionsClient() {
size="sm"
className="w-full"
disabled={isPending || !rid}
- onClick={() =>
- run(() => quickRoomKick({ roomId: rid }), {
- successMessage: "All users kicked from room",
- })
- }
+ onClick={handleRoomKick}
>
Kick All From Room
- {/* Broadcast */}
@@ -216,19 +248,7 @@ export function ModActionsClient() {
diff --git a/src/app/admin/moderation/cfh/cfh-table.tsx b/src/app/admin/moderation/cfh/cfh-table.tsx
new file mode 100644
index 00000000..f5790cfa
--- /dev/null
+++ b/src/app/admin/moderation/cfh/cfh-table.tsx
@@ -0,0 +1,117 @@
+"use client";
+
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { DataTable } from "@/components/admin/data-table";
+import { Badge } from "@/components/ui/badge";
+import type { DataTableColumn, PaginatedResult } from "@/types";
+
+export interface CfhRow {
+ id: number;
+ issue: string;
+ roomId: number;
+ when: string;
+ sender: string;
+ senderId: number;
+ reported: string;
+ reportedId: number;
+ mod: string;
+ state: number;
+ stateLabel: string;
+}
+
+export function CfhTable({ data }: { data: PaginatedResult }) {
+ const t = useTranslations("pages.admin.cfh");
+
+ const columns: DataTableColumn[] = [
+ { key: "id", label: t("colId"), sortable: true },
+ {
+ key: "issue",
+ label: t("colIssue"),
+ sortable: true,
+ render: (_value, row) => (
+
+
+ {row.issue || t("noDescription")}
+
+
+ {t("roomMeta", { room: row.roomId, when: row.when })}
+
+
+ ),
+ },
+ {
+ key: "sender",
+ label: t("colSender"),
+ sortable: true,
+ render: (_value, row) =>
+ row.senderId ? (
+
+ {row.sender}
+
+ ) : (
+ "—"
+ ),
+ },
+ {
+ key: "reported",
+ label: t("colReported"),
+ sortable: true,
+ render: (_value, row) =>
+ row.reportedId ? (
+
+ {row.reported}
+
+ ) : (
+ "—"
+ ),
+ },
+ { key: "mod", label: t("colMod"), sortable: true },
+ {
+ key: "state",
+ label: t("colStatus"),
+ sortable: true,
+ filterKey: "filter_state",
+ filterOptions: [
+ { label: t("stateOpen"), value: "0" },
+ { label: t("statePicked"), value: "1" },
+ { label: t("stateClosed"), value: "2" },
+ ],
+ render: (_value, row) => {
+ const variant =
+ row.state === 0
+ ? "destructive"
+ : row.state === 1
+ ? "default"
+ : "secondary";
+ return (
+
+ {row.stateLabel}
+
+ );
+ },
+ },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/app/admin/moderation/cfh/page.tsx b/src/app/admin/moderation/cfh/page.tsx
index a345be83..e2ed52a1 100644
--- a/src/app/admin/moderation/cfh/page.tsx
+++ b/src/app/admin/moderation/cfh/page.tsx
@@ -1,186 +1,157 @@
-import { AlertTriangle, CheckCircle2, Clock, Radio } from "lucide-react";
-import Link from "next/link";
import { redirect } from "next/navigation";
-import { Badge } from "@/components/ui/badge";
+import { getTranslations } from "next-intl/server";
+import type { Prisma } from "@/generated/prisma/client";
+import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
+import { CfhTable, type CfhRow } from "./cfh-table";
-const CFH_STATES: Record<
- number,
- { label: string; class: string; icon: typeof AlertTriangle }
-> = {
- 0: {
- label: "Open",
- class: "bg-[var(--admin-error-subtle)] text-[var(--admin-error)]",
- icon: AlertTriangle,
- },
- 1: {
- label: "Picked",
- class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
- icon: Clock,
- },
- 2: {
- label: "Closed",
- class: "bg-muted text-muted-foreground",
- icon: CheckCircle2,
- },
-};
+export const dynamic = "force-dynamic";
-interface PageProps {
- searchParams: Promise<{ state?: string; page?: string }>;
-}
-
-export default async function CfhListPage({ searchParams }: PageProps) {
+export default async function CfhListPage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
const { session, permissions } = await getAdminContext();
- if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank))
+ if (!canAccess(permissions, PERMS.MODERATION_VIEW, session.user.rank)) {
redirect("/admin");
+ }
- const sp = await searchParams;
- const stateFilter = sp.state !== undefined ? Number(sp.state) : -1;
- const page = Math.max(1, Number(sp.page) || 1);
- const perPage = 25;
+ const t = await getTranslations("pages.admin.cfh");
+ const raw = await searchParams;
+ const parsed = parseListParams(new URLSearchParams(raw));
+ const stateRaw = raw.filter_state ?? raw.state;
+ const stateFilter =
+ stateRaw !== undefined && stateRaw !== "" ? Number(stateRaw) : -1;
- const where = stateFilter >= 0 ? { state: stateFilter } : {};
+ const conditions: Prisma.SupportTicketsWhereInput[] = [];
+ if (stateFilter >= 0) {
+ conditions.push({ state: stateFilter });
+ }
- const [tickets, total] = await Promise.all([
- prisma.supportTickets.findMany({
+ if (parsed.search.trim()) {
+ const q = parsed.search.trim();
+ const asId = Number(q);
+ let userIds: number[] = [];
+ try {
+ const users = await prisma.user.findMany({
+ where: { username: { contains: q } },
+ select: { id: true },
+ take: 50,
+ });
+ userIds = users.map((u) => u.id);
+ } catch {
+ userIds = [];
+ }
+
+ conditions.push({
+ OR: [
+ { issue: { contains: q } },
+ ...(Number.isFinite(asId) && asId > 0 ? [{ id: asId }] : []),
+ ...(userIds.length
+ ? [
+ { senderId: { in: userIds } },
+ { reportedId: { in: userIds } },
+ { modId: { in: userIds } },
+ ]
+ : []),
+ ],
+ });
+ }
+
+ const where: Prisma.SupportTicketsWhereInput =
+ conditions.length === 0
+ ? {}
+ : conditions.length === 1
+ ? conditions[0]!
+ : { AND: conditions };
+
+ const SORT_MAP: Record =
+ {
+ id: { id: "desc" },
+ issue: { issue: "asc" },
+ sender: { senderId: "asc" },
+ reported: { reportedId: "asc" },
+ mod: { modId: "asc" },
+ state: { state: "asc" },
+ };
+
+ const baseOrder = SORT_MAP[parsed.sort ?? "id"] ?? { id: "desc" };
+ const orderField = Object.keys(baseOrder)[0] as keyof typeof baseOrder;
+ const orderBy = {
+ [orderField]: parsed.order,
+ } as Prisma.SupportTicketsOrderByWithRelationInput;
+
+ const total = await prisma.supportTickets.count({ where }).catch(() => 0);
+ const pagination = calcPagination(total, parsed.page, parsed.perPage);
+
+ const tickets = await prisma.supportTickets
+ .findMany({
where,
- orderBy: { id: "desc" },
- skip: (page - 1) * perPage,
- take: perPage,
- }),
- prisma.supportTickets.count({ where }),
- ]);
+ orderBy,
+ skip: pagination.offset,
+ take: pagination.perPage,
+ })
+ .catch(() => []);
- // Fetch all related users
const userIds = [
...new Set([
- ...tickets.map((t) => t.senderId),
- ...tickets.map((t) => t.reportedId),
- ...tickets.map((t) => t.modId),
+ ...tickets.map((x) => x.senderId),
+ ...tickets.map((x) => x.reportedId),
+ ...tickets.map((x) => x.modId),
]),
].filter(Boolean);
- const users =
- userIds.length > 0
- ? await prisma.user.findMany({
- where: { id: { in: userIds } },
- select: { id: true, username: true },
- })
- : [];
- const userMap = new Map(users.map((u) => [u.id, u.username]));
+ let userMap = new Map();
+ if (userIds.length > 0) {
+ try {
+ const users = await prisma.user.findMany({
+ where: { id: { in: userIds } },
+ select: { id: true, username: true },
+ });
+ userMap = new Map(users.map((u) => [u.id, u.username]));
+ } catch {
+ userMap = new Map();
+ }
+ }
- const totalPages = Math.ceil(total / perPage);
+ const stateLabels: Record = {
+ 0: t("stateOpen"),
+ 1: t("statePicked"),
+ 2: t("stateClosed"),
+ };
+
+ const rows: CfhRow[] = tickets.map((ticket) => ({
+ id: ticket.id,
+ issue: ticket.issue,
+ roomId: ticket.roomId,
+ when: ticket.timestamp
+ ? new Date(ticket.timestamp * 1000).toISOString().slice(0, 16).replace("T", " ")
+ : "—",
+ sender: userMap.get(ticket.senderId) ?? "—",
+ senderId: ticket.senderId,
+ reported: userMap.get(ticket.reportedId) ?? "—",
+ reportedId: ticket.reportedId,
+ mod: userMap.get(ticket.modId) ?? "—",
+ state: ticket.state,
+ stateLabel: stateLabels[ticket.state] ?? String(ticket.state),
+ }));
return (
- {/* Filters */}
-
- {[
- { key: -1, label: "All" },
- { key: 0, label: "Open" },
- { key: 1, label: "Picked" },
- { key: 2, label: "Closed" },
- ].map(({ key, label }) => (
- = 0 ? `?state=${key}` : ""}`}
- >
-
- {label}
-
-
- ))}
-
-
- {/* Table */}
-
-
- #
- Issue
- Sender
- Reported
- Mod
- Status
-
-
- {tickets.length === 0 ? (
-
-
- No tickets found.
-
- ) : (
- tickets.map((ticket) => {
- const state = CFH_STATES[ticket.state] ?? CFH_STATES[0];
- const StateIcon = state.icon;
-
- return (
-
-
- {ticket.id}
-
-
-
- {ticket.issue || "No description"}
-
-
- Room #{ticket.roomId} ·{" "}
- {new Date(ticket.timestamp * 1000).toLocaleString()}
-
-
-
- {userMap.get(ticket.senderId) ?? "—"}
-
-
- {userMap.get(ticket.reportedId) ?? "—"}
-
-
- {userMap.get(ticket.modId) ?? "—"}
-
-
-
- {state.label}
-
-
- );
- })
- )}
-
-
- {/* Pagination */}
- {totalPages > 1 && (
-
- {page > 1 && (
-
-
- Previous
-
-
- )}
-
- Page {page} of {totalPages}
-
- {page < totalPages && (
-
-
- Next
-
-
- )}
-
+ {total === 0 && !parsed.search && stateFilter < 0 ? (
+
{t("noTickets")}
+ ) : (
+
)}
);
diff --git a/src/app/admin/tickets/page.tsx b/src/app/admin/tickets/page.tsx
index bb6bb1d2..dba3cb0a 100644
--- a/src/app/admin/tickets/page.tsx
+++ b/src/app/admin/tickets/page.tsx
@@ -1,250 +1,151 @@
-import {
- AlertCircle,
- CheckCircle2,
- Clock,
- Loader2,
- Ticket,
-} from "lucide-react";
-import Link from "next/link";
import { redirect } from "next/navigation";
-import { Badge } from "@/components/ui/badge";
-import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { getTranslations } from "next-intl/server";
+import type { Prisma } from "@/generated/prisma/client";
+import { StatusCard } from "@/components/admin/dashboard";
+import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { prisma } from "@/lib/prisma";
+import { TicketsTable, type TicketRow } from "./tickets-table";
-const STATUS_CONFIG = {
- open: {
- label: "Open",
- icon: AlertCircle,
- class: "bg-[var(--admin-info-subtle)] text-[var(--admin-info)]",
- },
- in_progress: {
- label: "In Progress",
- icon: Loader2,
- class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
- },
- waiting: {
- label: "Waiting",
- icon: Clock,
- class: "bg-[var(--admin-warning-subtle)] text-[var(--admin-warning)]",
- },
- closed: {
- label: "Closed",
- icon: CheckCircle2,
- class: "bg-muted text-muted-foreground",
- },
-} as const;
+export const dynamic = "force-dynamic";
-const PRIORITY_COLORS = {
- low: "",
- normal: "text-[var(--admin-info)]",
- high: "text-[var(--admin-warning)]",
- urgent: "text-[var(--admin-error)] font-bold",
-};
-
-interface PageProps {
- searchParams: Promise<{ status?: string; page?: string }>;
+function fromDate(d: Date | null | undefined): string {
+ return d ? d.toISOString().slice(0, 10) : "—";
}
-export default async function AdminTicketsPage({ searchParams }: PageProps) {
+export default async function AdminTicketsPage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
const { session, permissions } = await getAdminContext();
if (!canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank)) {
redirect("/admin");
}
- const sp = await searchParams;
- const statusFilter = sp.status || "active";
- const page = Math.max(1, Number(sp.page) || 1);
- const perPage = 20;
+ 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 where =
- statusFilter === "active"
- ? { status: { not: "closed" } }
- : statusFilter === "all"
- ? {}
- : { status: statusFilter };
+ const conditions: Prisma.WebsiteTicketWhereInput[] = [];
- const [tickets, total, stats] = await Promise.all([
- prisma.websiteTicket.findMany({
+ 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 =
+ {
+ 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: [{ createdAt: "desc" }],
- skip: (page - 1) * perPage,
- take: perPage,
+ orderBy,
+ skip: pagination.offset,
+ take: pagination.perPage,
include: {
creator: { select: { id: true, username: true } },
assignee: { select: { id: true, username: true } },
_count: { select: { messages: true } },
},
- }),
- prisma.websiteTicket.count({ where }),
- Promise.all([
- prisma.websiteTicket.count({ where: { status: "open" } }),
- prisma.websiteTicket.count({ where: { status: "in_progress" } }),
- prisma.websiteTicket.count({ where: { status: "waiting" } }),
- prisma.websiteTicket.count({ where: { status: "closed" } }),
- ]),
- ]);
+ })
+ .catch(() => []);
- const [openCount, inProgressCount, waitingCount, closedCount] = stats;
- const totalPages = Math.ceil(total / perPage);
+ 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 (
- {/* Stats */}
-
-
-
-
- Open
-
-
-
- {openCount}
-
-
-
-
-
- In Progress
-
-
-
- {inProgressCount}
-
-
-
-
-
- Waiting
-
-
-
- {waitingCount}
-
-
-
-
-
- Closed
-
-
-
- {closedCount}
-
-
+
+
+ 0 ? "warn" : "neutral"}
+ icon="⏳"
+ />
+ 0 ? "warn" : "neutral"}
+ icon="🕒"
+ />
+
- {/* Filter tabs */}
-
- {[
- { key: "active", label: "Active" },
- { key: "open", label: "Open" },
- { key: "in_progress", label: "In Progress" },
- { key: "waiting", label: "Waiting" },
- { key: "closed", label: "Closed" },
- { key: "all", label: "All" },
- ].map(({ key, label }) => (
-
-
- {label}
-
-
- ))}
-
-
- {/* Ticket list */}
-
-
- Ticket
- Status
- Priority
- Assigned
- Date
-
-
- {tickets.length === 0 ? (
-
-
- No tickets found.
-
- ) : (
- tickets.map((ticket) => {
- const status =
- STATUS_CONFIG[ticket.status as keyof typeof STATUS_CONFIG] ??
- STATUS_CONFIG.open;
- const StatusIcon = status.icon;
-
- return (
-
-
-
-
- #{ticket.id}
-
-
- {ticket.subject}
-
-
-
- by {ticket.creator.username} ·{" "}
- {ticket._count.messages} msg · {ticket.category}
-
-
-
-
- {status.label}
-
-
- {ticket.priority}
-
-
- {ticket.assignee?.username ?? "—"}
-
-
- {new Date(ticket.createdAt).toLocaleDateString()}
-
-
- );
- })
- )}
-
-
- {/* Pagination */}
- {totalPages > 1 && (
-
- {page > 1 && (
-
-
- Previous
-
-
- )}
-
- Page {page} of {totalPages}
-
- {page < totalPages && (
-
-
- Next
-
-
- )}
-
+ {total === 0 && !parsed.search && statusFilter === "active" ? (
+
{t("noTickets")}
+ ) : (
+
)}
);
diff --git a/src/app/admin/tickets/tickets-table.tsx b/src/app/admin/tickets/tickets-table.tsx
new file mode 100644
index 00000000..95336302
--- /dev/null
+++ b/src/app/admin/tickets/tickets-table.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import Link from "next/link";
+import { useTranslations } from "next-intl";
+import { DataTable } from "@/components/admin/data-table";
+import { Badge } from "@/components/ui/badge";
+import type { DataTableColumn, PaginatedResult } from "@/types";
+
+export interface TicketRow {
+ id: number;
+ subject: string;
+ creator: string;
+ creatorId: number;
+ category: string;
+ messages: number;
+ status: string;
+ priority: string;
+ assignee: string;
+ date: string;
+}
+
+const STATUS_VARIANT: Record<
+ string,
+ "default" | "secondary" | "destructive" | "outline"
+> = {
+ open: "default",
+ in_progress: "secondary",
+ waiting: "outline",
+ closed: "secondary",
+};
+
+export function TicketsTable({ data }: { data: PaginatedResult
}) {
+ const t = useTranslations("pages.admin.tickets");
+
+ const statusLabel: Record = {
+ open: t("statusOpen"),
+ in_progress: t("statusInProgress"),
+ waiting: t("statusWaiting"),
+ closed: t("statusClosed"),
+ };
+
+ const columns: DataTableColumn[] = [
+ {
+ key: "subject",
+ label: t("colTicket"),
+ sortable: true,
+ render: (_value, row) => (
+
+
+
+ #{row.id}
+
+
+ {row.subject}
+
+
+
+ {t("meta", {
+ user: row.creator,
+ count: row.messages,
+ category: row.category,
+ })}
+
+
+ ),
+ },
+ {
+ key: "status",
+ label: t("colStatus"),
+ sortable: true,
+ filterKey: "filter_status",
+ filterOptions: [
+ { label: t("statusOpen"), value: "open" },
+ { label: t("statusInProgress"), value: "in_progress" },
+ { label: t("statusWaiting"), value: "waiting" },
+ { label: t("statusClosed"), value: "closed" },
+ ],
+ render: (value) => {
+ const status = String(value);
+ return (
+
+ {statusLabel[status] ?? status.replace("_", " ")}
+
+ );
+ },
+ },
+ {
+ key: "priority",
+ label: t("colPriority"),
+ sortable: true,
+ render: (value) => {
+ const p = String(value);
+ const className =
+ p === "urgent"
+ ? "text-[var(--admin-error)] font-bold"
+ : p === "high"
+ ? "text-[var(--admin-warning)]"
+ : p === "normal"
+ ? "text-[var(--admin-info)]"
+ : "";
+ return {p};
+ },
+ },
+ { key: "assignee", label: t("colAssigned"), sortable: true },
+ { key: "date", label: t("colDate"), sortable: true },
+ ];
+
+ return (
+
+ );
+}
diff --git a/src/messages/en.json b/src/messages/en.json
index f718ffb5..a11aa309 100644
--- a/src/messages/en.json
+++ b/src/messages/en.json
@@ -1092,6 +1092,40 @@
"saving": "Saving…",
"settingsSaved": "VPN settings saved successfully"
},
+ "tickets": {
+ "title": "Tickets",
+ "subtitle": "Support tickets from players",
+ "noTickets": "No active tickets",
+ "searchPlaceholder": "Search subject, user, category or ID…",
+ "presetActive": "Active",
+ "statusOpen": "Open",
+ "statusInProgress": "In progress",
+ "statusWaiting": "Waiting",
+ "statusClosed": "Closed",
+ "colTicket": "Ticket",
+ "colStatus": "Status",
+ "colPriority": "Priority",
+ "colAssigned": "Assigned",
+ "colDate": "Date",
+ "meta": "by {user} · {count} msg · {category}"
+ },
+ "cfh": {
+ "title": "Call for Help",
+ "subtitle": "In-game CFH / support tickets",
+ "noTickets": "No CFH tickets",
+ "searchPlaceholder": "Search issue, user or ID…",
+ "noDescription": "No description",
+ "roomMeta": "Room #{room} · {when}",
+ "stateOpen": "Open",
+ "statePicked": "Picked",
+ "stateClosed": "Closed",
+ "colId": "ID",
+ "colIssue": "Issue",
+ "colSender": "Sender",
+ "colReported": "Reported",
+ "colMod": "Mod",
+ "colStatus": "Status"
+ },
"applications": {
"title": "Staff Applications",
"subtitle": "Pending and reviewed staff applications",
diff --git a/src/messages/it.json b/src/messages/it.json
index aa0b1e79..cd82261f 100644
--- a/src/messages/it.json
+++ b/src/messages/it.json
@@ -1056,6 +1056,40 @@
"saving": "Salvataggio…",
"settingsSaved": "Impostazioni VPN salvate con successo"
},
+ "tickets": {
+ "title": "Ticket",
+ "subtitle": "Ticket di supporto dai giocatori",
+ "noTickets": "Nessun ticket attivo",
+ "searchPlaceholder": "Cerca oggetto, utente, categoria o ID…",
+ "presetActive": "Attivi",
+ "statusOpen": "Aperti",
+ "statusInProgress": "In corso",
+ "statusWaiting": "In attesa",
+ "statusClosed": "Chiusi",
+ "colTicket": "Ticket",
+ "colStatus": "Stato",
+ "colPriority": "Priorità",
+ "colAssigned": "Assegnato",
+ "colDate": "Data",
+ "meta": "di {user} · {count} msg · {category}"
+ },
+ "cfh": {
+ "title": "Call for Help",
+ "subtitle": "Ticket CFH in-game",
+ "noTickets": "Nessun ticket CFH",
+ "searchPlaceholder": "Cerca problema, utente o ID…",
+ "noDescription": "Nessuna descrizione",
+ "roomMeta": "Stanza #{room} · {when}",
+ "stateOpen": "Aperti",
+ "statePicked": "Presi in carico",
+ "stateClosed": "Chiusi",
+ "colId": "ID",
+ "colIssue": "Problema",
+ "colSender": "Mittente",
+ "colReported": "Segnalato",
+ "colMod": "Mod",
+ "colStatus": "Stato"
+ },
"applications": {
"title": "Candidature staff",
"subtitle": "Candidature allo staff in sospeso e revisionate",