feat(support): filter waiting tickets and staff assignments
CI / check (push) Failing after 53s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-11 00:20:54 +02:00
1 parent 5d18af932d
commit 25d0a13050
7 files changed
+173 -6

No files matched your search

+24 -2
View File
@@ -15,7 +15,14 @@ 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 {
awaitingStaffCondition,
latestTicketReplyAt,
latestTicketReplyIsStaff,
ticketAssignmentCondition,
} from "@/lib/admin/ticket-desk-query";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { ticketReplyAge } from "@/lib/admin/ticket-reply-age";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User, WebsiteTicket } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
@@ -41,8 +48,15 @@ export default async function AdminTicketsDeskPage({
const Assignee = alias(User, "ticket_assignee");
const conditions: SQL[] = [];
const assignment = ticketAssignmentCondition(
raw.filter_assignee,
session.user.id,
);
if (assignment) conditions.push(assignment);
if (statusFilter === "active") {
if (statusFilter === "awaiting_staff") {
if (awaitingStaffCondition) conditions.push(awaitingStaffCondition);
} else if (statusFilter === "active") {
conditions.push(ne(WebsiteTicket.status, "closed"));
} else if (statusFilter && statusFilter !== "all") {
conditions.push(eq(WebsiteTicket.status, statusFilter));
@@ -130,6 +144,8 @@ export default async function AdminTicketsDeskPage({
createdAt: WebsiteTicket.createdAt,
creatorUsername: Creator.username,
assigneeUsername: Assignee.username,
replyAt: latestTicketReplyAt,
replyIsStaff: latestTicketReplyIsStaff,
messageCount: sql<number>`(
SELECT COUNT(*) FROM website_ticket_messages m
WHERE m.ticket_id = ${WebsiteTicket.id}
@@ -152,6 +168,9 @@ export default async function AdminTicketsDeskPage({
category: ticket.category,
messages: Number(ticket.messageCount ?? 0),
status: ticket.status,
awaitingStaff:
ticket.status !== "closed" && Number(ticket.replyIsStaff) === 0,
replyAgeMinutes: ticketReplyAge(ticket.replyAt),
priority: ticket.priority,
assignee: ticket.assigneeUsername ?? "—",
date: formatDate(ticket.createdAt, "date"),
@@ -185,7 +204,10 @@ export default async function AdminTicketsDeskPage({
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "active" ? (
{total === 0 &&
!parsed.search &&
statusFilter === "active" &&
!raw.filter_assignee ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<TicketsTable
+46 -1
View File
@@ -17,6 +17,8 @@ export interface TicketRow {
priority: string;
assignee: string;
date: string;
awaitingStaff: boolean;
replyAgeMinutes: number | null;
}
const STATUS_VARIANT: Record<
@@ -79,6 +81,7 @@ export function TicketsTable({
sortable: true,
filterKey: "filter_status",
filterOptions: [
{ label: t("awaitingStaff"), value: "awaiting_staff" },
{ label: t("statusOpen"), value: "open" },
{ label: t("statusInProgress"), value: "in_progress" },
{ label: t("statusWaiting"), value: "waiting" },
@@ -113,7 +116,41 @@ export function TicketsTable({
return <span className={`text-xs capitalize ${className}`}>{p}</span>;
},
},
{ key: "assignee", label: t("colAssigned"), sortable: true },
{
key: "assignee",
label: t("colAssigned"),
sortable: true,
filterKey: "filter_assignee",
filterOptions: [
{ label: t("assignedMine"), value: "mine" },
{ label: t("unassigned"), value: "unassigned" },
{ label: t("assignedAny"), value: "assigned" },
],
},
{
key: "replyAgeMinutes",
label: t("lastReplyAge"),
render: (_value, row) => (
<div>
{row.awaitingStaff && (
<Badge variant="outline">{t("awaitingStaff")}</Badge>
)}
<div className="text-xs text-muted-foreground">
{row.replyAgeMinutes === null
? "—"
: row.replyAgeMinutes < 60
? t("ageMinutes", { count: row.replyAgeMinutes })
: row.replyAgeMinutes < 1440
? t("ageHours", {
count: Math.floor(row.replyAgeMinutes / 60),
})
: t("ageDays", {
count: Math.floor(row.replyAgeMinutes / 1440),
})}
</div>
</div>
),
},
{ key: "date", label: t("colDate"), sortable: true },
];
@@ -123,6 +160,14 @@ export function TicketsTable({
columns={columns}
searchPlaceholder={t("searchPlaceholder")}
presets={[
{
label: t("awaitingStaff"),
params: { filter_status: "awaiting_staff", filter_assignee: "" },
},
{
label: t("assignedMine"),
params: { filter_status: "active", filter_assignee: "mine" },
},
{ label: t("presetActive"), params: { filter_status: "active" } },
{ label: t("statusOpen"), params: { filter_status: "open" } },
{
+25 -3
View File
@@ -19,7 +19,14 @@ import {
import { StatusCard } from "@/components/admin/dashboard";
import { TicketQueueBanner } from "@/components/admin/ticket-queue-banner";
import { requireModPermission } from "@/lib/admin/guard";
import {
awaitingStaffCondition,
latestTicketReplyAt,
latestTicketReplyIsStaff,
ticketAssignmentCondition,
} from "@/lib/admin/ticket-desk-query";
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
import { ticketReplyAge } from "@/lib/admin/ticket-reply-age";
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
import { formatDate } from "@/lib/format-date";
@@ -42,7 +49,10 @@ export default async function ModTicketsDeskPage({
}: {
searchParams: Promise<Record<string, string>>;
}) {
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
const staff = await requireModPermission([
PERMS.MOD_TICKETS_VIEW,
PERMS.TICKETS_VIEW,
]);
const t = await getTranslations("pages.admin.tickets");
const raw = await searchParams;
@@ -50,8 +60,12 @@ export default async function ModTicketsDeskPage({
const statusFilter = raw.filter_status || raw.status || "active";
const conditions: SQL[] = [];
const assignment = ticketAssignmentCondition(raw.filter_assignee, staff.id);
if (assignment) conditions.push(assignment);
if (statusFilter === "active") {
if (statusFilter === "awaiting_staff") {
if (awaitingStaffCondition) conditions.push(awaitingStaffCondition);
} else if (statusFilter === "active") {
conditions.push(ne(WebsiteTicket.status, "closed"));
} else if (statusFilter && statusFilter !== "all") {
conditions.push(eq(WebsiteTicket.status, statusFilter));
@@ -137,6 +151,8 @@ export default async function ModTicketsDeskPage({
createdAt: WebsiteTicket.createdAt,
creatorUsername: Creator.username,
assigneeUsername: Assignee.username,
replyAt: latestTicketReplyAt,
replyIsStaff: latestTicketReplyIsStaff,
})
.from(WebsiteTicket)
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
@@ -170,6 +186,9 @@ export default async function ModTicketsDeskPage({
category: ticket.category,
messages: msgMap.get(ticket.id) ?? 0,
status: ticket.status,
awaitingStaff:
ticket.status !== "closed" && Number(ticket.replyIsStaff) === 0,
replyAgeMinutes: ticketReplyAge(ticket.replyAt),
priority: ticket.priority,
assignee: ticket.assigneeUsername ?? "—",
date: formatDate(ticket.createdAt, "date"),
@@ -203,7 +222,10 @@ export default async function ModTicketsDeskPage({
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
</div>
{total === 0 && !parsed.search && statusFilter === "active" ? (
{total === 0 &&
!parsed.search &&
statusFilter === "active" &&
!raw.filter_assignee ? (
<div className="admin-empty">{t("noTickets")}</div>
) : (
<TicketsTable