feat(admin): unified ticket inbox over CMS and help-center queues
CI / check (push) Successful in 22s
CI / release (push) Skipped
CI / deploy (push) Successful in 57s

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:
SimoandCursor committed 2026-08-01 14:49:19 +02:00
1 parent 24d0b735c1
commit d8199ea1e4
31 files changed
+1580 -394

No files matched your search

+204
View File
@@ -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
View File
@@ -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,
}}
/>
)}
-7
View File
@@ -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"),
+221
View File
@@ -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
View File
@@ -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" },
},
]}
/>
);
}
+6 -1
View File
@@ -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",
+327
View File
@@ -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,
};
}
+21 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {