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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"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 { TicketInboxRow } from "@/lib/admin/ticket-inbox";
|
||||
import type { DataTableColumn, PaginatedResult } from "@/types/common";
|
||||
|
||||
export function UnifiedTicketsTable({
|
||||
data,
|
||||
}: {
|
||||
data: PaginatedResult<TicketInboxRow>;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.ticketInbox");
|
||||
|
||||
const columns: DataTableColumn<TicketInboxRow>[] = [
|
||||
{
|
||||
key: "title",
|
||||
label: t("colTicket"),
|
||||
sortable: false,
|
||||
render: (_value, row) => (
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge
|
||||
variant={row.kind === "cms" ? "default" : "outline"}
|
||||
className="shrink-0 text-[0.65rem]"
|
||||
>
|
||||
{row.kind === "cms" ? t("typeCms") : t("typeHelp")}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground font-mono">
|
||||
#{row.id}
|
||||
</span>
|
||||
<Link
|
||||
href={row.href}
|
||||
className="font-medium truncate hover:underline"
|
||||
>
|
||||
{row.title}
|
||||
</Link>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("meta", { user: row.user })}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
label: t("colStatus"),
|
||||
sortable: false,
|
||||
filterKey: "filter_status",
|
||||
filterOptions: [
|
||||
{ label: t("statusOpen"), value: "open" },
|
||||
{ label: t("statusAll"), value: "all" },
|
||||
],
|
||||
render: (_value, row) => (
|
||||
<Badge
|
||||
variant={row.open ? "default" : "secondary"}
|
||||
className="text-[0.7rem] capitalize"
|
||||
>
|
||||
{row.kind === "cms"
|
||||
? row.status.replace("_", " ")
|
||||
: row.open
|
||||
? t("statusOpen")
|
||||
: t("statusClosed")}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "kind",
|
||||
label: t("colType"),
|
||||
sortable: false,
|
||||
filterKey: "filter_type",
|
||||
filterOptions: [
|
||||
{ label: t("typeAll"), value: "all" },
|
||||
{ label: t("typeCms"), value: "cms" },
|
||||
{ label: t("typeHelp"), value: "help" },
|
||||
],
|
||||
render: (value) => {
|
||||
const kind = String(value);
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{kind === "cms" ? t("typeCms") : t("typeHelp")}
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ key: "date", label: t("colUpdated"), sortable: false },
|
||||
];
|
||||
|
||||
return (
|
||||
<DataTable
|
||||
data={data}
|
||||
columns={columns}
|
||||
searchPlaceholder={t("searchPlaceholder")}
|
||||
presets={[
|
||||
{
|
||||
label: t("presetOpen"),
|
||||
params: { filter_status: "open", filter_type: "all" },
|
||||
},
|
||||
{
|
||||
label: t("typeCms"),
|
||||
params: { filter_type: "cms", filter_status: "open" },
|
||||
},
|
||||
{
|
||||
label: t("typeHelp"),
|
||||
params: { filter_type: "help", filter_status: "open" },
|
||||
},
|
||||
{
|
||||
label: t("statusAll"),
|
||||
params: { filter_status: "all", filter_type: "all" },
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -109,9 +109,14 @@ export const ADMIN_HUBS: AdminHubDefinition[] = [
|
||||
tabs: [
|
||||
{
|
||||
href: "/admin/tickets",
|
||||
labelKey: "tickets",
|
||||
labelKey: "ticketInbox",
|
||||
match: ["/admin/tickets"],
|
||||
},
|
||||
{
|
||||
href: "/admin/tickets/desk",
|
||||
labelKey: "tickets",
|
||||
match: ["/admin/tickets/desk"],
|
||||
},
|
||||
{
|
||||
href: "/admin/help-tickets",
|
||||
labelKey: "helpTickets",
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
import "server-only";
|
||||
|
||||
import {
|
||||
and,
|
||||
count,
|
||||
desc,
|
||||
eq,
|
||||
inArray,
|
||||
like,
|
||||
ne,
|
||||
or,
|
||||
type SQL,
|
||||
} from "drizzle-orm";
|
||||
import { alias } from "drizzle-orm/mysql-core";
|
||||
import { calcPagination } from "@/lib/admin-helpers";
|
||||
import { db, User, WebsiteHelpCenterTickets, WebsiteTicket } from "@/lib/db";
|
||||
import { formatDate } from "@/lib/format-date";
|
||||
|
||||
export type TicketInboxKind = "cms" | "help";
|
||||
export type TicketInboxTypeFilter = "all" | TicketInboxKind;
|
||||
|
||||
export interface TicketInboxRow {
|
||||
key: string;
|
||||
kind: TicketInboxKind;
|
||||
id: string;
|
||||
title: string;
|
||||
user: string;
|
||||
userId: number | null;
|
||||
status: string;
|
||||
open: boolean;
|
||||
sortAt: number;
|
||||
date: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
export interface FetchTicketInboxOptions {
|
||||
page: number;
|
||||
perPage: number;
|
||||
search?: string;
|
||||
type?: TicketInboxTypeFilter;
|
||||
/** Default true: only non-closed CMS + open help-center. */
|
||||
openOnly?: boolean;
|
||||
base?: "admin" | "mod";
|
||||
}
|
||||
|
||||
function toSortMs(value: Date | string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const ms =
|
||||
value instanceof Date ? value.getTime() : Date.parse(String(value));
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
async function fetchCmsCandidates(
|
||||
limit: number,
|
||||
search: string,
|
||||
openOnly: boolean,
|
||||
base: "admin" | "mod",
|
||||
): Promise<TicketInboxRow[]> {
|
||||
const Creator = alias(User, "inbox_ticket_creator");
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (openOnly) {
|
||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const asId = Number(search);
|
||||
const searchOr = [
|
||||
like(WebsiteTicket.subject, `%${search}%`),
|
||||
like(WebsiteTicket.category, `%${search}%`),
|
||||
like(Creator.username, `%${search}%`),
|
||||
...(Number.isFinite(asId) && asId > 0
|
||||
? [eq(WebsiteTicket.id, asId)]
|
||||
: []),
|
||||
];
|
||||
conditions.push(or(...searchOr)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteTicket.id,
|
||||
subject: WebsiteTicket.subject,
|
||||
status: WebsiteTicket.status,
|
||||
creatorId: WebsiteTicket.creatorId,
|
||||
updatedAt: WebsiteTicket.updatedAt,
|
||||
createdAt: WebsiteTicket.createdAt,
|
||||
creatorUsername: Creator.username,
|
||||
})
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.where(where)
|
||||
.orderBy(desc(WebsiteTicket.updatedAt), desc(WebsiteTicket.id))
|
||||
.limit(limit)
|
||||
.catch(() => []);
|
||||
|
||||
const prefix = base === "mod" ? "/mod/tickets" : "/admin/tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `cms-${row.id}`,
|
||||
kind: "cms" as const,
|
||||
id: String(row.id),
|
||||
title: row.subject,
|
||||
user: row.creatorUsername ?? "—",
|
||||
userId: row.creatorId,
|
||||
status: row.status,
|
||||
open: row.status !== "closed",
|
||||
sortAt: toSortMs(row.updatedAt ?? row.createdAt),
|
||||
date: formatDate(row.updatedAt ?? row.createdAt, "date"),
|
||||
href: `${prefix}/${row.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function fetchHelpCandidates(
|
||||
limit: number,
|
||||
search: string,
|
||||
openOnly: boolean,
|
||||
base: "admin" | "mod",
|
||||
): Promise<TicketInboxRow[]> {
|
||||
const conditions: SQL[] = [];
|
||||
|
||||
if (openOnly) {
|
||||
conditions.push(eq(WebsiteHelpCenterTickets.open, true));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const asId = /^\d+$/.test(search) ? BigInt(search) : null;
|
||||
const matchingUsers = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(like(User.username, `%${search}%`))
|
||||
.limit(50)
|
||||
.catch(() => []);
|
||||
|
||||
const searchOr = [
|
||||
like(WebsiteHelpCenterTickets.title, `%${search}%`),
|
||||
like(WebsiteHelpCenterTickets.content, `%${search}%`),
|
||||
...(matchingUsers.length > 0
|
||||
? [
|
||||
inArray(
|
||||
WebsiteHelpCenterTickets.userId,
|
||||
matchingUsers.map((u) => u.id),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []),
|
||||
];
|
||||
conditions.push(or(...searchOr)!);
|
||||
}
|
||||
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: WebsiteHelpCenterTickets.id,
|
||||
title: WebsiteHelpCenterTickets.title,
|
||||
open: WebsiteHelpCenterTickets.open,
|
||||
userId: WebsiteHelpCenterTickets.userId,
|
||||
updatedAt: WebsiteHelpCenterTickets.updatedAt,
|
||||
createdAt: WebsiteHelpCenterTickets.createdAt,
|
||||
})
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(where)
|
||||
.orderBy(
|
||||
desc(WebsiteHelpCenterTickets.updatedAt),
|
||||
desc(WebsiteHelpCenterTickets.id),
|
||||
)
|
||||
.limit(limit)
|
||||
.catch(() => []);
|
||||
|
||||
const userIds = [
|
||||
...new Set(
|
||||
rows.map((row) => row.userId).filter((id): id is number => id != null),
|
||||
),
|
||||
];
|
||||
const users =
|
||||
userIds.length > 0
|
||||
? await db
|
||||
.select({ id: User.id, username: User.username })
|
||||
.from(User)
|
||||
.where(inArray(User.id, userIds))
|
||||
.catch(() => [])
|
||||
: [];
|
||||
const usernameById = new Map(users.map((u) => [u.id, u.username]));
|
||||
|
||||
const prefix = base === "mod" ? "/mod/help-tickets" : "/admin/help-tickets";
|
||||
|
||||
return rows.map((row) => ({
|
||||
key: `help-${row.id}`,
|
||||
kind: "help" as const,
|
||||
id: String(row.id),
|
||||
title: row.title,
|
||||
user:
|
||||
row.userId != null
|
||||
? (usernameById.get(row.userId) ?? `#${row.userId}`)
|
||||
: "—",
|
||||
userId: row.userId,
|
||||
status: row.open ? "open" : "closed",
|
||||
open: row.open,
|
||||
sortAt: toSortMs(row.updatedAt ?? row.createdAt),
|
||||
date: formatDate(row.updatedAt ?? row.createdAt, "date"),
|
||||
href: `${prefix}/${row.id}`,
|
||||
}));
|
||||
}
|
||||
|
||||
async function countCms(search: string, openOnly: boolean): Promise<number> {
|
||||
const Creator = alias(User, "inbox_ticket_creator_count");
|
||||
const conditions: SQL[] = [];
|
||||
if (openOnly) conditions.push(ne(WebsiteTicket.status, "closed"));
|
||||
if (search) {
|
||||
const asId = Number(search);
|
||||
conditions.push(
|
||||
or(
|
||||
like(WebsiteTicket.subject, `%${search}%`),
|
||||
like(WebsiteTicket.category, `%${search}%`),
|
||||
like(Creator.username, `%${search}%`),
|
||||
...(Number.isFinite(asId) && asId > 0
|
||||
? [eq(WebsiteTicket.id, asId)]
|
||||
: []),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const rows = await db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteTicket)
|
||||
.leftJoin(Creator, eq(WebsiteTicket.creatorId, Creator.id))
|
||||
.where(where)
|
||||
.catch(() => [{ total: 0 }]);
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
async function countHelp(search: string, openOnly: boolean): Promise<number> {
|
||||
const conditions: SQL[] = [];
|
||||
if (openOnly) conditions.push(eq(WebsiteHelpCenterTickets.open, true));
|
||||
if (search) {
|
||||
const asId = /^\d+$/.test(search) ? BigInt(search) : null;
|
||||
const matchingUsers = await db
|
||||
.select({ id: User.id })
|
||||
.from(User)
|
||||
.where(like(User.username, `%${search}%`))
|
||||
.limit(50)
|
||||
.catch(() => []);
|
||||
conditions.push(
|
||||
or(
|
||||
like(WebsiteHelpCenterTickets.title, `%${search}%`),
|
||||
like(WebsiteHelpCenterTickets.content, `%${search}%`),
|
||||
...(matchingUsers.length > 0
|
||||
? [
|
||||
inArray(
|
||||
WebsiteHelpCenterTickets.userId,
|
||||
matchingUsers.map((u) => u.id),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...(asId !== null ? [eq(WebsiteHelpCenterTickets.id, asId)] : []),
|
||||
)!,
|
||||
);
|
||||
}
|
||||
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
const rows = await db
|
||||
.select({ total: count() })
|
||||
.from(WebsiteHelpCenterTickets)
|
||||
.where(where)
|
||||
.catch(() => [{ total: 0 }]);
|
||||
return Number(rows[0]?.total ?? 0);
|
||||
}
|
||||
|
||||
/** Merged read-model over CMS desk + help-center queues (no DB merge). */
|
||||
export async function fetchUnifiedTicketInbox(
|
||||
options: FetchTicketInboxOptions,
|
||||
): Promise<{
|
||||
rows: TicketInboxRow[];
|
||||
total: number;
|
||||
page: number;
|
||||
perPage: number;
|
||||
lastPage: number;
|
||||
cmsTotal: number;
|
||||
helpTotal: number;
|
||||
}> {
|
||||
const type = options.type ?? "all";
|
||||
const openOnly = options.openOnly !== false;
|
||||
const search = options.search?.trim() ?? "";
|
||||
const base = options.base ?? "admin";
|
||||
|
||||
const includeCms = type === "all" || type === "cms";
|
||||
const includeHelp = type === "all" || type === "help";
|
||||
|
||||
const [cmsTotal, helpTotal] = await Promise.all([
|
||||
includeCms ? countCms(search, openOnly) : Promise.resolve(0),
|
||||
includeHelp ? countHelp(search, openOnly) : Promise.resolve(0),
|
||||
]);
|
||||
|
||||
const total = cmsTotal + helpTotal;
|
||||
const pagination = calcPagination(total, options.page, options.perPage);
|
||||
const window = pagination.offset + pagination.perPage;
|
||||
|
||||
const [cmsRows, helpRows] = await Promise.all([
|
||||
includeCms
|
||||
? fetchCmsCandidates(window, search, openOnly, base)
|
||||
: Promise.resolve([]),
|
||||
includeHelp
|
||||
? fetchHelpCandidates(window, search, openOnly, base)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
|
||||
const merged = [...cmsRows, ...helpRows].sort((a, b) => {
|
||||
if (b.sortAt !== a.sortAt) return b.sortAt - a.sortAt;
|
||||
return a.key < b.key ? 1 : -1;
|
||||
});
|
||||
|
||||
const rows = merged.slice(
|
||||
pagination.offset,
|
||||
pagination.offset + pagination.perPage,
|
||||
);
|
||||
|
||||
return {
|
||||
rows,
|
||||
total,
|
||||
page: pagination.page,
|
||||
perPage: pagination.perPage,
|
||||
lastPage: pagination.lastPage,
|
||||
cmsTotal,
|
||||
helpTotal,
|
||||
};
|
||||
}
|
||||
@@ -125,9 +125,9 @@ describe("staff smoke contract", () => {
|
||||
|
||||
it("guards dual ticket queues on admin and mod", () => {
|
||||
for (const path of [
|
||||
"src/app/admin/tickets/page.tsx",
|
||||
"src/app/admin/tickets/desk/page.tsx",
|
||||
"src/app/admin/help-tickets/page.tsx",
|
||||
"src/app/mod/tickets/page.tsx",
|
||||
"src/app/mod/tickets/desk/page.tsx",
|
||||
"src/app/mod/help-tickets/page.tsx",
|
||||
]) {
|
||||
expect(existsSync(path), path).toBe(true);
|
||||
@@ -135,6 +135,25 @@ describe("staff smoke contract", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("ships unified ticket inbox over both queues", () => {
|
||||
expect(existsSync("src/lib/admin/ticket-inbox.ts")).toBe(true);
|
||||
expect(readFileSync("src/lib/admin/ticket-inbox.ts", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(existsSync("src/components/admin/unified-tickets-table.tsx")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(readFileSync("src/app/admin/tickets/page.tsx", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(readFileSync("src/app/mod/tickets/page.tsx", "utf8")).toContain(
|
||||
"fetchUnifiedTicketInbox",
|
||||
);
|
||||
expect(readFileSync("src/lib/admin-nav.ts", "utf8")).toContain(
|
||||
"ticketInbox",
|
||||
);
|
||||
});
|
||||
|
||||
it("ships items_base browser", () => {
|
||||
expect(existsSync("src/app/admin/items/page.tsx")).toBe(true);
|
||||
expect(existsSync("src/app/admin/items/[id]/page.tsx")).toBe(true);
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2565,7 +2565,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2870,6 +2871,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+32
-7
@@ -981,7 +981,7 @@
|
||||
"advertisements": "Advertisements",
|
||||
"writeableBoxes": "Writeable boxes",
|
||||
"photos": "Photos",
|
||||
"tickets": "Support desk",
|
||||
"tickets": "Tickets",
|
||||
"helpTickets": "Help-center tickets",
|
||||
"events": "Events",
|
||||
"polls": "Polls",
|
||||
@@ -1510,7 +1510,7 @@
|
||||
"tickets": {
|
||||
"title": "CMS support desk",
|
||||
"subtitle": "Internal CMS tickets (assignee, priority, staff workflow). Not the same as help-center tickets from /help.",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab.",
|
||||
"queueHint": "This queue is the CMS support desk. Player help-center tickets live in the Help-center tickets tab. Use Inbox for both queues.",
|
||||
"queueOtherCta": "Open help-center tickets",
|
||||
"queueCounts": "Open here: {here} · Help center: {other}",
|
||||
"noTickets": "No active tickets",
|
||||
@@ -1527,11 +1527,35 @@
|
||||
"colDate": "Date",
|
||||
"meta": "by {user} · {count} msg · {category}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product’s own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
},
|
||||
"helpTickets": {
|
||||
"title": "Help center tickets",
|
||||
"subtitle": "Tickets players open at /help/tickets. Separate from the CMS support desk.",
|
||||
"queueHint": "This queue is the public help center. CMS support desk tickets are in the Support desk tab.",
|
||||
"queueOtherCta": "Open CMS support desk",
|
||||
"queueHint": "This queue is the public help center. Use Inbox for both queues, or Support desk for CMS-only.",
|
||||
"queueOtherCta": "Open unified inbox",
|
||||
"queueCounts": "Open here: {here} · Support desk: {other}",
|
||||
"noTickets": "No open help center tickets",
|
||||
"searchPlaceholder": "Search title, user or ID…",
|
||||
@@ -2516,6 +2540,7 @@
|
||||
"eventTypes": "Event types",
|
||||
"polls": "Polls",
|
||||
"tickets": "Support desk",
|
||||
"ticketInbox": "Inbox",
|
||||
"helpTickets": "Help center",
|
||||
"templates": "Templates",
|
||||
"directory": "Directory",
|
||||
@@ -2577,8 +2602,8 @@
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
"tickets": "Support desk",
|
||||
"ticketsSubtitle": "Two separate queues: CMS support desk and public help-center tickets",
|
||||
"tickets": "Tickets",
|
||||
"ticketsSubtitle": "Unified inbox over CMS support desk and help-center queues",
|
||||
"staffAccess": "Ranks & permissions",
|
||||
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
|
||||
"analytics": "Analytics",
|
||||
@@ -3080,7 +3105,7 @@
|
||||
"bans": "Bans",
|
||||
"fullAdmin": "Full admin",
|
||||
"backToSite": "Back to site",
|
||||
"tickets": "Support desk",
|
||||
"tickets": "Tickets",
|
||||
"helpTickets": "Help-center tickets",
|
||||
"users": "Users",
|
||||
"team": "Team"
|
||||
|
||||
+26
-1
@@ -2565,7 +2565,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2870,6 +2871,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+26
-1
@@ -2565,7 +2565,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2870,6 +2871,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+29
-4
@@ -952,7 +952,7 @@
|
||||
"advertisements": "Pubblicità",
|
||||
"writeableBoxes": "Box modificabili",
|
||||
"photos": "Foto",
|
||||
"tickets": "Assistenza",
|
||||
"tickets": "Ticket",
|
||||
"helpTickets": "Ticket centro assistenza",
|
||||
"events": "Eventi",
|
||||
"polls": "Sondaggi",
|
||||
@@ -2812,12 +2812,13 @@
|
||||
"devops": "DevOps",
|
||||
"errors": "Errori",
|
||||
"alerts": "Alert",
|
||||
"toolsGroup": "Strumenti"
|
||||
"toolsGroup": "Strumenti",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Eventi",
|
||||
"eventsSubtitle": "Eventi hotel e tipi evento",
|
||||
"tickets": "Assistenza",
|
||||
"ticketsSubtitle": "Due code separate: supporto CMS e ticket help center pubblici",
|
||||
"tickets": "Ticket",
|
||||
"ticketsSubtitle": "Inbox unificata su supporto CMS e help center",
|
||||
"staffAccess": "Gradi e permessi",
|
||||
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
|
||||
"analytics": "Analitiche",
|
||||
@@ -3042,6 +3043,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Vista unificata dei ticket aperti CMS e help center. I dettagli restano sulle route di ciascun prodotto.",
|
||||
"noTickets": "Nessun ticket aperto in nessuna coda",
|
||||
"searchPlaceholder": "Cerca oggetto, titolo, utente o ID…",
|
||||
"statCmsOpen": "CMS aperti",
|
||||
"statHelpOpen": "Help aperti",
|
||||
"statTotal": "Totale aperti",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Stato",
|
||||
"colType": "Coda",
|
||||
"colUpdated": "Aggiornato",
|
||||
"meta": "di {user}",
|
||||
"typeAll": "Tutte le code",
|
||||
"typeCms": "Supporto CMS",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Aperti",
|
||||
"statusClosed": "Chiusi",
|
||||
"statusAll": "Tutti gli stati",
|
||||
"presetOpen": "Solo aperti",
|
||||
"modNavLabel": "Code:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "Supporto CMS",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"radioRequests": {
|
||||
|
||||
+26
-1
@@ -2853,7 +2853,8 @@
|
||||
"devops": "DevOps",
|
||||
"errors": "Fouten",
|
||||
"alerts": "Meldingen",
|
||||
"toolsGroup": "Tools"
|
||||
"toolsGroup": "Tools",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Evenementen",
|
||||
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
||||
@@ -3051,6 +3052,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"mod": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2593,7 +2593,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2898,6 +2899,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
+26
-1
@@ -2595,7 +2595,8 @@
|
||||
"errors": "Errors",
|
||||
"alerts": "Alerts",
|
||||
"toolsGroup": "Tools",
|
||||
"helpTickets": "Help-center tickets"
|
||||
"helpTickets": "Help-center tickets",
|
||||
"ticketInbox": "Inbox"
|
||||
},
|
||||
"events": "Events",
|
||||
"eventsSubtitle": "Hotel events and event types",
|
||||
@@ -2900,6 +2901,30 @@
|
||||
"title": "Online users",
|
||||
"subtitle": "Players currently connected",
|
||||
"connected": "{count, plural, one {# player connected} other {# players connected}}"
|
||||
},
|
||||
"ticketInbox": {
|
||||
"hint": "Merged view of open CMS desk and help-center tickets. Detail pages and replies stay on each product own route.",
|
||||
"noTickets": "No open tickets in either queue",
|
||||
"searchPlaceholder": "Search subject, title, user or ID…",
|
||||
"statCmsOpen": "CMS open",
|
||||
"statHelpOpen": "Help open",
|
||||
"statTotal": "Open total",
|
||||
"colTicket": "Ticket",
|
||||
"colStatus": "Status",
|
||||
"colType": "Queue",
|
||||
"colUpdated": "Updated",
|
||||
"meta": "by {user}",
|
||||
"typeAll": "All queues",
|
||||
"typeCms": "CMS desk",
|
||||
"typeHelp": "Help center",
|
||||
"statusOpen": "Open",
|
||||
"statusClosed": "Closed",
|
||||
"statusAll": "All statuses",
|
||||
"presetOpen": "Open only",
|
||||
"modNavLabel": "Queues:",
|
||||
"modNavInbox": "Inbox",
|
||||
"modNavDesk": "CMS desk",
|
||||
"modNavHelp": "Help center"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
Reference in new issue
Block a user