feat(admin): unified ticket inbox over CMS and help-center queues
Merged read-model inbox at /admin/tickets and /mod/tickets with type badges and deep links; CMS-only lists moved to /desk. No DB schema merge. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
1 parent
24d0b735c1
commit
d8199ea1e4
31 files changed
+1580
-394
No files matched your search
@@ -0,0 +1,204 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, User, WebsiteTicket } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { type TicketRow, TicketsTable } from "../tickets-table";
|
||||
|
||||
export default async function AdminTicketsDeskPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
const { session, permissions } = await getAdminContext();
|
||||
if (!canAccess(permissions, PERMS.TICKETS_VIEW, session.user.rank)) {
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
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 Creator = alias(User, "ticket_creator");
|
||||
const Assignee = alias(User, "ticket_assignee");
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
} else if (statusFilter && statusFilter !== "all") {
|
||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
||||
}
|
||||
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
const asId = Number(q);
|
||||
const searchOr = [
|
||||
like(WebsiteTicket.subject, `%${q}%`),
|
||||
like(WebsiteTicket.category, `%${q}%`),
|
||||
like(Creator.username, `%${q}%`),
|
||||
like(Assignee.username, `%${q}%`),
|
||||
...(Number.isFinite(asId) && asId > 0
|
||||
? [eq(WebsiteTicket.id, asId)]
|
||||
: []),
|
||||
];
|
||||
conditions.push(or(...searchOr)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const orderCol =
|
||||
parsed.sort === "subject"
|
||||
? WebsiteTicket.subject
|
||||
: parsed.sort === "status"
|
||||
? WebsiteTicket.status
|
||||
: parsed.sort === "priority"
|
||||
? WebsiteTicket.priority
|
||||
: parsed.sort === "assignee"
|
||||
? WebsiteTicket.assigneeId
|
||||
: parsed.sort === "id"
|
||||
? WebsiteTicket.id
|
||||
: WebsiteTicket.createdAt;
|
||||
const orderBy = parsed.order === "asc" ? asc(orderCol) : desc(orderCol);
|
||||
|
||||
const [totalRows, openRows, inProgressRows, waitingRows, closedRows, queues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "open"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "in_progress"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "waiting"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "closed"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const total = Number(totalRows[0]?.total ?? 0);
|
||||
const openCount = Number(openRows[0]?.total ?? 0);
|
||||
const inProgressCount = Number(inProgressRows[0]?.total ?? 0);
|
||||
const waitingCount = Number(waitingRows[0]?.total ?? 0);
|
||||
const closedCount = Number(closedRows[0]?.total ?? 0);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
creatorId: WebsiteTicket.creatorId,
|
||||
category: WebsiteTicket.category,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
creatorUsername: Creator.username,
|
||||
assigneeUsername: Assignee.username,
|
||||
messageCount: sql<number>`(
|
||||
SELECT COUNT(*) FROM website_ticket_messages m
|
||||
WHERE m.ticket_id = ${WebsiteTicket.id}
|
||||
)`.mapWith(Number),
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.orderBy(orderBy)
|
||||
.limit(pagination.perPage)
|
||||
.offset(pagination.offset)
|
||||
.catch(() => []);
|
||||
|
||||
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
creator: ticket.creatorUsername ?? "—",
|
||||
creatorId: ticket.creatorId,
|
||||
category: ticket.category,
|
||||
messages: Number(ticket.messageCount ?? 0),
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assigneeUsername ?? "—",
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<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="/admin/tickets"
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+40
-166
@@ -1,28 +1,16 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
sql,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { redirect } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { UnifiedTicketsTable } from "@/components/admin/unified-tickets-table";
|
||||
import {
|
||||
fetchUnifiedTicketInbox,
|
||||
type TicketInboxTypeFilter,
|
||||
} from "@/lib/admin/ticket-inbox";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, User, WebsiteTicket } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { parseListParams } from "@/lib/admin-helpers";
|
||||
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
||||
import { type TicketRow, TicketsTable } from "./tickets-table";
|
||||
|
||||
export default async function AdminTicketsPage({
|
||||
export default async function AdminTicketInboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
@@ -32,169 +20,55 @@ export default async function AdminTicketsPage({
|
||||
redirect("/admin");
|
||||
}
|
||||
|
||||
const t = await getTranslations("pages.admin.tickets");
|
||||
const t = await getTranslations("pages.admin.ticketInbox");
|
||||
const raw = await searchParams;
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || raw.status || "active";
|
||||
const typeRaw = raw.filter_type || "all";
|
||||
const type: TicketInboxTypeFilter =
|
||||
typeRaw === "cms" || typeRaw === "help" ? typeRaw : "all";
|
||||
const statusFilter = raw.filter_status || "open";
|
||||
const openOnly = statusFilter !== "all";
|
||||
|
||||
const Creator = alias(User, "ticket_creator");
|
||||
const Assignee = alias(User, "ticket_assignee");
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
} else if (statusFilter && statusFilter !== "all") {
|
||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
||||
}
|
||||
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
const asId = Number(q);
|
||||
const searchOr = [
|
||||
like(WebsiteTicket.subject, `%${q}%`),
|
||||
like(WebsiteTicket.category, `%${q}%`),
|
||||
like(Creator.username, `%${q}%`),
|
||||
like(Assignee.username, `%${q}%`),
|
||||
...(Number.isFinite(asId) && asId > 0
|
||||
? [eq(WebsiteTicket.id, asId)]
|
||||
: []),
|
||||
];
|
||||
conditions.push(or(...searchOr)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const orderCol =
|
||||
parsed.sort === "subject"
|
||||
? WebsiteTicket.subject
|
||||
: parsed.sort === "status"
|
||||
? WebsiteTicket.status
|
||||
: parsed.sort === "priority"
|
||||
? WebsiteTicket.priority
|
||||
: parsed.sort === "assignee"
|
||||
? WebsiteTicket.assigneeId
|
||||
: parsed.sort === "id"
|
||||
? WebsiteTicket.id
|
||||
: WebsiteTicket.createdAt;
|
||||
const orderBy = parsed.order === "asc" ? asc(orderCol) : desc(orderCol);
|
||||
|
||||
const [totalRows, openRows, inProgressRows, waitingRows, closedRows, queues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "open"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "in_progress"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "waiting"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "closed"))
|
||||
.catch(() => [{ total: 0 }]),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const total = Number(totalRows[0]?.total ?? 0);
|
||||
const openCount = Number(openRows[0]?.total ?? 0);
|
||||
const inProgressCount = Number(inProgressRows[0]?.total ?? 0);
|
||||
const waitingCount = Number(waitingRows[0]?.total ?? 0);
|
||||
const closedCount = Number(closedRows[0]?.total ?? 0);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
creatorId: WebsiteTicket.creatorId,
|
||||
category: WebsiteTicket.category,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
creatorUsername: Creator.username,
|
||||
assigneeUsername: Assignee.username,
|
||||
messageCount: sql<number>`(
|
||||
SELECT COUNT(*) FROM website_ticket_messages m
|
||||
WHERE m.ticket_id = ${WebsiteTicket.id}
|
||||
)`.mapWith(Number),
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.orderBy(orderBy)
|
||||
.limit(pagination.perPage)
|
||||
.offset(pagination.offset)
|
||||
.catch(() => []);
|
||||
|
||||
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
creator: ticket.creatorUsername ?? "—",
|
||||
creatorId: ticket.creatorId,
|
||||
category: ticket.category,
|
||||
messages: Number(ticket.messageCount ?? 0),
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assigneeUsername ?? "—",
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
const [inbox, queues] = await Promise.all([
|
||||
fetchUnifiedTicketInbox({
|
||||
page: parsed.page,
|
||||
perPage: parsed.perPage,
|
||||
search: parsed.search,
|
||||
type,
|
||||
openOnly,
|
||||
base: "admin",
|
||||
}),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/admin/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<p className="m-0 text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard label={t("statCmsOpen")} value={queues.cmsOpen} icon="🎫" />
|
||||
<StatusCard
|
||||
label={t("statusInProgress")}
|
||||
value={inProgressCount}
|
||||
state={inProgressCount > 0 ? "warn" : "neutral"}
|
||||
icon="⏳"
|
||||
label={t("statHelpOpen")}
|
||||
value={queues.helpOpen}
|
||||
state={queues.helpOpen > 0 ? "warn" : "neutral"}
|
||||
icon="🛟"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("statusWaiting")}
|
||||
value={waitingCount}
|
||||
state={waitingCount > 0 ? "warn" : "neutral"}
|
||||
icon="🕒"
|
||||
label={t("statTotal")}
|
||||
value={queues.cmsOpen + queues.helpOpen}
|
||||
icon="📥"
|
||||
/>
|
||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||
</div>
|
||||
|
||||
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
||||
{inbox.total === 0 && !parsed.search ? (
|
||||
<div className="admin-empty">{t("noTickets")}</div>
|
||||
) : (
|
||||
<TicketsTable
|
||||
<UnifiedTicketsTable
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
rows: inbox.rows,
|
||||
total: inbox.total,
|
||||
page: inbox.page,
|
||||
perPage: inbox.perPage,
|
||||
lastPage: inbox.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { eq } from "drizzle-orm";
|
||||
import {
|
||||
Ban,
|
||||
Gavel,
|
||||
LifeBuoy,
|
||||
LogOut,
|
||||
Radio,
|
||||
Shield,
|
||||
@@ -73,12 +72,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
||||
icon: Ticket,
|
||||
show: showTickets,
|
||||
},
|
||||
{
|
||||
href: "/mod/help-tickets",
|
||||
label: t("nav.helpTickets"),
|
||||
icon: LifeBuoy,
|
||||
show: showTickets,
|
||||
},
|
||||
{
|
||||
href: "/mod/actions",
|
||||
label: t("nav.actions"),
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
type TicketRow,
|
||||
TicketsTable,
|
||||
} from "@/app/admin/tickets/tickets-table";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const Creator = alias(User, "ticket_creator");
|
||||
const Assignee = alias(User, "ticket_assignee");
|
||||
|
||||
const SORT_COLS = {
|
||||
subject: WebsiteTicket.subject,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
assignee: WebsiteTicket.assigneeId,
|
||||
date: WebsiteTicket.createdAt,
|
||||
id: WebsiteTicket.id,
|
||||
} as const;
|
||||
|
||||
export default async function ModTicketsDeskPage({
|
||||
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: SQL[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
} else if (statusFilter && statusFilter !== "all") {
|
||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
||||
}
|
||||
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
const asId = Number(q);
|
||||
const ors: SQL[] = [
|
||||
like(WebsiteTicket.subject, `%${q}%`),
|
||||
like(WebsiteTicket.category, `%${q}%`),
|
||||
like(Creator.username, `%${q}%`),
|
||||
like(Assignee.username, `%${q}%`),
|
||||
];
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(WebsiteTicket.id, asId));
|
||||
}
|
||||
conditions.push(or(...ors)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const sortKey =
|
||||
parsed.sort && parsed.sort in SORT_COLS
|
||||
? (parsed.sort as keyof typeof SORT_COLS)
|
||||
: "date";
|
||||
const sortCol = SORT_COLS[sortKey];
|
||||
const finalOrder =
|
||||
parsed.sort && parsed.sort in SORT_COLS
|
||||
? parsed.order === "asc"
|
||||
? asc(sortCol)
|
||||
: desc(sortCol)
|
||||
: desc(WebsiteTicket.createdAt);
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "open"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "in_progress"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "waiting"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
category: WebsiteTicket.category,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
creatorId: WebsiteTicket.creatorId,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
creatorUsername: Creator.username,
|
||||
assigneeUsername: Assignee.username,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.orderBy(finalOrder)
|
||||
.offset(pagination.offset)
|
||||
.limit(pagination.perPage)
|
||||
.catch(() => []);
|
||||
|
||||
const ticketIds = tickets.map((ticket) => ticket.id);
|
||||
const msgRows =
|
||||
ticketIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
ticketId: WebsiteTicketMessage.ticketId,
|
||||
total: count(),
|
||||
})
|
||||
.from(WebsiteTicketMessage)
|
||||
.where(inArray(WebsiteTicketMessage.ticketId, ticketIds))
|
||||
.groupBy(WebsiteTicketMessage.ticketId)
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const msgMap = new Map(msgRows.map((m) => [m.ticketId, m.total]));
|
||||
|
||||
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
creator: ticket.creatorUsername ?? "—",
|
||||
creatorId: ticket.creatorId,
|
||||
category: ticket.category,
|
||||
messages: msgMap.get(ticket.id) ?? 0,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assigneeUsername ?? "—",
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/mod/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+64
-187
@@ -1,218 +1,95 @@
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import {
|
||||
type TicketRow,
|
||||
TicketsTable,
|
||||
} from "@/app/admin/tickets/tickets-table";
|
||||
import { StatusCard } from "@/components/admin/dashboard";
|
||||
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
|
||||
import { UnifiedTicketsTable } from "@/components/admin/unified-tickets-table";
|
||||
import { requireModPermission } from "@/lib/admin/guard";
|
||||
import {
|
||||
fetchUnifiedTicketInbox,
|
||||
type TicketInboxTypeFilter,
|
||||
} from "@/lib/admin/ticket-inbox";
|
||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
||||
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
import { parseListParams } from "@/lib/admin-helpers";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const Creator = alias(User, "ticket_creator");
|
||||
const Assignee = alias(User, "ticket_assignee");
|
||||
|
||||
const SORT_COLS = {
|
||||
subject: WebsiteTicket.subject,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
assignee: WebsiteTicket.assigneeId,
|
||||
date: WebsiteTicket.createdAt,
|
||||
id: WebsiteTicket.id,
|
||||
} as const;
|
||||
|
||||
export default async function ModTicketsPage({
|
||||
export default async function ModTicketInboxPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Promise<Record<string, string>>;
|
||||
}) {
|
||||
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
||||
|
||||
const t = await getTranslations("pages.admin.tickets");
|
||||
const t = await getTranslations("pages.admin.ticketInbox");
|
||||
const raw = await searchParams;
|
||||
const parsed = parseListParams(new URLSearchParams(raw));
|
||||
const statusFilter = raw.filter_status || raw.status || "active";
|
||||
const typeRaw = raw.filter_type || "all";
|
||||
const type: TicketInboxTypeFilter =
|
||||
typeRaw === "cms" || typeRaw === "help" ? typeRaw : "all";
|
||||
const statusFilter = raw.filter_status || "open";
|
||||
const openOnly = statusFilter !== "all";
|
||||
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (statusFilter === "active") {
|
||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
} else if (statusFilter && statusFilter !== "all") {
|
||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
||||
}
|
||||
|
||||
if (parsed.search.trim()) {
|
||||
const q = parsed.search.trim();
|
||||
const asId = Number(q);
|
||||
const ors: SQL[] = [
|
||||
like(WebsiteTicket.subject, `%${q}%`),
|
||||
like(WebsiteTicket.category, `%${q}%`),
|
||||
like(Creator.username, `%${q}%`),
|
||||
like(Assignee.username, `%${q}%`),
|
||||
];
|
||||
if (Number.isFinite(asId) && asId > 0) {
|
||||
ors.push(eq(WebsiteTicket.id, asId));
|
||||
}
|
||||
conditions.push(or(...ors)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const sortKey =
|
||||
parsed.sort && parsed.sort in SORT_COLS
|
||||
? (parsed.sort as keyof typeof SORT_COLS)
|
||||
: "date";
|
||||
const sortCol = SORT_COLS[sortKey];
|
||||
const finalOrder =
|
||||
parsed.sort && parsed.sort in SORT_COLS
|
||||
? parsed.order === "asc"
|
||||
? asc(sortCol)
|
||||
: desc(sortCol)
|
||||
: desc(WebsiteTicket.createdAt);
|
||||
|
||||
const [total, openCount, inProgressCount, waitingCount, closedCount, queues] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "open"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "in_progress"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "waiting"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.where(eq(WebsiteTicket.status, "closed"))
|
||||
.then((rows) => rows[0]?.total ?? 0)
|
||||
.catch(() => 0),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
const pagination = calcPagination(total, parsed.page, parsed.perPage);
|
||||
|
||||
const tickets = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
category: WebsiteTicket.category,
|
||||
status: WebsiteTicket.status,
|
||||
priority: WebsiteTicket.priority,
|
||||
creatorId: WebsiteTicket.creatorId,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
creatorUsername: Creator.username,
|
||||
assigneeUsername: Assignee.username,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.leftJoin(Assignee, eq(WebsiteTicket.assigneeId, Assignee.id))
|
||||
.where(where)
|
||||
.orderBy(finalOrder)
|
||||
.offset(pagination.offset)
|
||||
.limit(pagination.perPage)
|
||||
.catch(() => []);
|
||||
|
||||
const ticketIds = tickets.map((ticket) => ticket.id);
|
||||
const msgRows =
|
||||
ticketIds.length > 0
|
||||
? await db
|
||||
.select({
|
||||
ticketId: WebsiteTicketMessage.ticketId,
|
||||
total: count(),
|
||||
})
|
||||
.from(WebsiteTicketMessage)
|
||||
.where(inArray(WebsiteTicketMessage.ticketId, ticketIds))
|
||||
.groupBy(WebsiteTicketMessage.ticketId)
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const msgMap = new Map(msgRows.map((m) => [m.ticketId, m.total]));
|
||||
|
||||
const rows: TicketRow[] = tickets.map((ticket) => ({
|
||||
id: ticket.id,
|
||||
subject: ticket.subject,
|
||||
creator: ticket.creatorUsername ?? "—",
|
||||
creatorId: ticket.creatorId,
|
||||
category: ticket.category,
|
||||
messages: msgMap.get(ticket.id) ?? 0,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
assignee: ticket.assigneeUsername ?? "—",
|
||||
date: formatDate(ticket.createdAt, "date"),
|
||||
}));
|
||||
const [inbox, queues] = await Promise.all([
|
||||
fetchUnifiedTicketInbox({
|
||||
page: parsed.page,
|
||||
perPage: parsed.perPage,
|
||||
search: parsed.search,
|
||||
type,
|
||||
openOnly,
|
||||
base: "mod",
|
||||
}),
|
||||
fetchTicketQueueOpenCounts(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<TicketQueueBanner
|
||||
hint={t("queueHint")}
|
||||
otherHref="/mod/help-tickets"
|
||||
otherLabel={t("queueOtherCta")}
|
||||
countsLine={t("queueCounts", {
|
||||
here: queues.cmsOpen,
|
||||
other: queues.helpOpen,
|
||||
})}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="font-semibold text-[var(--admin-text)]">
|
||||
{t("modNavLabel")}
|
||||
</span>
|
||||
<Link
|
||||
href="/mod/tickets"
|
||||
className="rounded-md px-2 py-1 no-underline text-[var(--admin-accent)] hover:underline"
|
||||
>
|
||||
{t("modNavInbox")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/mod/tickets/desk"
|
||||
className="rounded-md px-2 py-1 no-underline text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{t("modNavDesk")}
|
||||
</Link>
|
||||
<Link
|
||||
href="/mod/help-tickets"
|
||||
className="rounded-md px-2 py-1 no-underline text-[var(--admin-text-muted)] hover:text-[var(--admin-text)]"
|
||||
>
|
||||
{t("modNavHelp")}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="m-0 text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(180px,1fr))] gap-3.5">
|
||||
<StatusCard label={t("statusOpen")} value={openCount} icon="🎫" />
|
||||
<StatusCard label={t("statCmsOpen")} value={queues.cmsOpen} icon="🎫" />
|
||||
<StatusCard
|
||||
label={t("statusInProgress")}
|
||||
value={inProgressCount}
|
||||
state={inProgressCount > 0 ? "warn" : "neutral"}
|
||||
icon="⏳"
|
||||
label={t("statHelpOpen")}
|
||||
value={queues.helpOpen}
|
||||
state={queues.helpOpen > 0 ? "warn" : "neutral"}
|
||||
icon="🛟"
|
||||
/>
|
||||
<StatusCard
|
||||
label={t("statusWaiting")}
|
||||
value={waitingCount}
|
||||
state={waitingCount > 0 ? "warn" : "neutral"}
|
||||
icon="🕒"
|
||||
label={t("statTotal")}
|
||||
value={queues.cmsOpen + queues.helpOpen}
|
||||
icon="📥"
|
||||
/>
|
||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
||||
</div>
|
||||
|
||||
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
||||
{inbox.total === 0 && !parsed.search ? (
|
||||
<div className="admin-empty">{t("noTickets")}</div>
|
||||
) : (
|
||||
<TicketsTable
|
||||
basePath="/mod/tickets"
|
||||
<UnifiedTicketsTable
|
||||
data={{
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
rows: inbox.rows,
|
||||
total: inbox.total,
|
||||
page: inbox.page,
|
||||
perPage: inbox.perPage,
|
||||
lastPage: inbox.lastPage,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user