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 { redirect } from "next/navigation";
|
||||||
import { getTranslations } from "next-intl/server";
|
import { getTranslations } from "next-intl/server";
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
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 { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { 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 { 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,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
@@ -32,169 +20,55 @@ export default async function AdminTicketsPage({
|
|||||||
redirect("/admin");
|
redirect("/admin");
|
||||||
}
|
}
|
||||||
|
|
||||||
const t = await getTranslations("pages.admin.tickets");
|
const t = await getTranslations("pages.admin.ticketInbox");
|
||||||
const raw = await searchParams;
|
const raw = await searchParams;
|
||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
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 [inbox, queues] = await Promise.all([
|
||||||
const Assignee = alias(User, "ticket_assignee");
|
fetchUnifiedTicketInbox({
|
||||||
|
page: parsed.page,
|
||||||
const conditions: SQL[] = [];
|
perPage: parsed.perPage,
|
||||||
|
search: parsed.search,
|
||||||
if (statusFilter === "active") {
|
type,
|
||||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
openOnly,
|
||||||
} else if (statusFilter && statusFilter !== "all") {
|
base: "admin",
|
||||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
}),
|
||||||
}
|
fetchTicketQueueOpenCounts(),
|
||||||
|
]);
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<TicketQueueBanner
|
<p className="m-0 text-sm text-[var(--admin-text-muted)]">{t("hint")}</p>
|
||||||
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">
|
<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
|
<StatusCard
|
||||||
label={t("statusInProgress")}
|
label={t("statHelpOpen")}
|
||||||
value={inProgressCount}
|
value={queues.helpOpen}
|
||||||
state={inProgressCount > 0 ? "warn" : "neutral"}
|
state={queues.helpOpen > 0 ? "warn" : "neutral"}
|
||||||
icon="⏳"
|
icon="🛟"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("statusWaiting")}
|
label={t("statTotal")}
|
||||||
value={waitingCount}
|
value={queues.cmsOpen + queues.helpOpen}
|
||||||
state={waitingCount > 0 ? "warn" : "neutral"}
|
icon="📥"
|
||||||
icon="🕒"
|
|
||||||
/>
|
/>
|
||||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
{inbox.total === 0 && !parsed.search ? (
|
||||||
<div className="admin-empty">{t("noTickets")}</div>
|
<div className="admin-empty">{t("noTickets")}</div>
|
||||||
) : (
|
) : (
|
||||||
<TicketsTable
|
<UnifiedTicketsTable
|
||||||
data={{
|
data={{
|
||||||
rows,
|
rows: inbox.rows,
|
||||||
total,
|
total: inbox.total,
|
||||||
page: pagination.page,
|
page: inbox.page,
|
||||||
perPage: pagination.perPage,
|
perPage: inbox.perPage,
|
||||||
lastPage: pagination.lastPage,
|
lastPage: inbox.lastPage,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { eq } from "drizzle-orm";
|
|||||||
import {
|
import {
|
||||||
Ban,
|
Ban,
|
||||||
Gavel,
|
Gavel,
|
||||||
LifeBuoy,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
Radio,
|
Radio,
|
||||||
Shield,
|
Shield,
|
||||||
@@ -73,12 +72,6 @@ export default async function ModLayout({ children }: { children: ReactNode }) {
|
|||||||
icon: Ticket,
|
icon: Ticket,
|
||||||
show: showTickets,
|
show: showTickets,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
href: "/mod/help-tickets",
|
|
||||||
label: t("nav.helpTickets"),
|
|
||||||
icon: LifeBuoy,
|
|
||||||
show: showTickets,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
href: "/mod/actions",
|
href: "/mod/actions",
|
||||||
label: t("nav.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 {
|
import Link from "next/link";
|
||||||
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 { getTranslations } from "next-intl/server";
|
||||||
import {
|
|
||||||
type TicketRow,
|
|
||||||
TicketsTable,
|
|
||||||
} from "@/app/admin/tickets/tickets-table";
|
|
||||||
import { StatusCard } from "@/components/admin/dashboard";
|
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 { requireModPermission } from "@/lib/admin/guard";
|
||||||
|
import {
|
||||||
|
fetchUnifiedTicketInbox,
|
||||||
|
type TicketInboxTypeFilter,
|
||||||
|
} from "@/lib/admin/ticket-inbox";
|
||||||
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
import { fetchTicketQueueOpenCounts } from "@/lib/admin/ticket-queue-counts";
|
||||||
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
import { parseListParams } from "@/lib/admin-helpers";
|
||||||
import { db, User, WebsiteTicket, WebsiteTicketMessage } from "@/lib/db";
|
|
||||||
import { formatDate } from "@/lib/format-date";
|
|
||||||
import { PERMS } from "@/lib/permissions";
|
import { PERMS } from "@/lib/permissions";
|
||||||
|
|
||||||
const Creator = alias(User, "ticket_creator");
|
export default async function ModTicketInboxPage({
|
||||||
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({
|
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
searchParams: Promise<Record<string, string>>;
|
searchParams: Promise<Record<string, string>>;
|
||||||
}) {
|
}) {
|
||||||
await requireModPermission([PERMS.MOD_TICKETS_VIEW, PERMS.TICKETS_VIEW]);
|
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 raw = await searchParams;
|
||||||
const parsed = parseListParams(new URLSearchParams(raw));
|
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[] = [];
|
const [inbox, queues] = await Promise.all([
|
||||||
|
fetchUnifiedTicketInbox({
|
||||||
if (statusFilter === "active") {
|
page: parsed.page,
|
||||||
conditions.push(ne(WebsiteTicket.status, "closed"));
|
perPage: parsed.perPage,
|
||||||
} else if (statusFilter && statusFilter !== "all") {
|
search: parsed.search,
|
||||||
conditions.push(eq(WebsiteTicket.status, statusFilter));
|
type,
|
||||||
}
|
openOnly,
|
||||||
|
base: "mod",
|
||||||
if (parsed.search.trim()) {
|
}),
|
||||||
const q = parsed.search.trim();
|
fetchTicketQueueOpenCounts(),
|
||||||
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<TicketQueueBanner
|
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
hint={t("queueHint")}
|
<span className="font-semibold text-[var(--admin-text)]">
|
||||||
otherHref="/mod/help-tickets"
|
{t("modNavLabel")}
|
||||||
otherLabel={t("queueOtherCta")}
|
</span>
|
||||||
countsLine={t("queueCounts", {
|
<Link
|
||||||
here: queues.cmsOpen,
|
href="/mod/tickets"
|
||||||
other: queues.helpOpen,
|
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">
|
<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
|
<StatusCard
|
||||||
label={t("statusInProgress")}
|
label={t("statHelpOpen")}
|
||||||
value={inProgressCount}
|
value={queues.helpOpen}
|
||||||
state={inProgressCount > 0 ? "warn" : "neutral"}
|
state={queues.helpOpen > 0 ? "warn" : "neutral"}
|
||||||
icon="⏳"
|
icon="🛟"
|
||||||
/>
|
/>
|
||||||
<StatusCard
|
<StatusCard
|
||||||
label={t("statusWaiting")}
|
label={t("statTotal")}
|
||||||
value={waitingCount}
|
value={queues.cmsOpen + queues.helpOpen}
|
||||||
state={waitingCount > 0 ? "warn" : "neutral"}
|
icon="📥"
|
||||||
icon="🕒"
|
|
||||||
/>
|
/>
|
||||||
<StatusCard label={t("statusClosed")} value={closedCount} icon="✅" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{total === 0 && !parsed.search && statusFilter === "active" ? (
|
{inbox.total === 0 && !parsed.search ? (
|
||||||
<div className="admin-empty">{t("noTickets")}</div>
|
<div className="admin-empty">{t("noTickets")}</div>
|
||||||
) : (
|
) : (
|
||||||
<TicketsTable
|
<UnifiedTicketsTable
|
||||||
basePath="/mod/tickets"
|
|
||||||
data={{
|
data={{
|
||||||
rows,
|
rows: inbox.rows,
|
||||||
total,
|
total: inbox.total,
|
||||||
page: pagination.page,
|
page: inbox.page,
|
||||||
perPage: pagination.perPage,
|
perPage: inbox.perPage,
|
||||||
lastPage: pagination.lastPage,
|
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: [
|
tabs: [
|
||||||
{
|
{
|
||||||
href: "/admin/tickets",
|
href: "/admin/tickets",
|
||||||
labelKey: "tickets",
|
labelKey: "ticketInbox",
|
||||||
match: ["/admin/tickets"],
|
match: ["/admin/tickets"],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: "/admin/tickets/desk",
|
||||||
|
labelKey: "tickets",
|
||||||
|
match: ["/admin/tickets/desk"],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: "/admin/help-tickets",
|
href: "/admin/help-tickets",
|
||||||
labelKey: "helpTickets",
|
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", () => {
|
it("guards dual ticket queues on admin and mod", () => {
|
||||||
for (const path of [
|
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/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",
|
"src/app/mod/help-tickets/page.tsx",
|
||||||
]) {
|
]) {
|
||||||
expect(existsSync(path), path).toBe(true);
|
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", () => {
|
it("ships items_base browser", () => {
|
||||||
expect(existsSync("src/app/admin/items/page.tsx")).toBe(true);
|
expect(existsSync("src/app/admin/items/page.tsx")).toBe(true);
|
||||||
expect(existsSync("src/app/admin/items/[id]/page.tsx")).toBe(true);
|
expect(existsSync("src/app/admin/items/[id]/page.tsx")).toBe(true);
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2565,7 +2565,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2870,6 +2871,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"radioRequests": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+32
-7
@@ -981,7 +981,7 @@
|
|||||||
"advertisements": "Advertisements",
|
"advertisements": "Advertisements",
|
||||||
"writeableBoxes": "Writeable boxes",
|
"writeableBoxes": "Writeable boxes",
|
||||||
"photos": "Photos",
|
"photos": "Photos",
|
||||||
"tickets": "Support desk",
|
"tickets": "Tickets",
|
||||||
"helpTickets": "Help-center tickets",
|
"helpTickets": "Help-center tickets",
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
@@ -1510,7 +1510,7 @@
|
|||||||
"tickets": {
|
"tickets": {
|
||||||
"title": "CMS support desk",
|
"title": "CMS support desk",
|
||||||
"subtitle": "Internal CMS tickets (assignee, priority, staff workflow). Not the same as help-center tickets from /help.",
|
"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",
|
"queueOtherCta": "Open help-center tickets",
|
||||||
"queueCounts": "Open here: {here} · Help center: {other}",
|
"queueCounts": "Open here: {here} · Help center: {other}",
|
||||||
"noTickets": "No active tickets",
|
"noTickets": "No active tickets",
|
||||||
@@ -1527,11 +1527,35 @@
|
|||||||
"colDate": "Date",
|
"colDate": "Date",
|
||||||
"meta": "by {user} · {count} msg · {category}"
|
"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": {
|
"helpTickets": {
|
||||||
"title": "Help center tickets",
|
"title": "Help center tickets",
|
||||||
"subtitle": "Tickets players open at /help/tickets. Separate from the CMS support desk.",
|
"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.",
|
"queueHint": "This queue is the public help center. Use Inbox for both queues, or Support desk for CMS-only.",
|
||||||
"queueOtherCta": "Open CMS support desk",
|
"queueOtherCta": "Open unified inbox",
|
||||||
"queueCounts": "Open here: {here} · Support desk: {other}",
|
"queueCounts": "Open here: {here} · Support desk: {other}",
|
||||||
"noTickets": "No open help center tickets",
|
"noTickets": "No open help center tickets",
|
||||||
"searchPlaceholder": "Search title, user or ID…",
|
"searchPlaceholder": "Search title, user or ID…",
|
||||||
@@ -2516,6 +2540,7 @@
|
|||||||
"eventTypes": "Event types",
|
"eventTypes": "Event types",
|
||||||
"polls": "Polls",
|
"polls": "Polls",
|
||||||
"tickets": "Support desk",
|
"tickets": "Support desk",
|
||||||
|
"ticketInbox": "Inbox",
|
||||||
"helpTickets": "Help center",
|
"helpTickets": "Help center",
|
||||||
"templates": "Templates",
|
"templates": "Templates",
|
||||||
"directory": "Directory",
|
"directory": "Directory",
|
||||||
@@ -2577,8 +2602,8 @@
|
|||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
"tickets": "Support desk",
|
"tickets": "Tickets",
|
||||||
"ticketsSubtitle": "Two separate queues: CMS support desk and public help-center tickets",
|
"ticketsSubtitle": "Unified inbox over CMS support desk and help-center queues",
|
||||||
"staffAccess": "Ranks & permissions",
|
"staffAccess": "Ranks & permissions",
|
||||||
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
|
"staffAccessSubtitle": "Staff ranks and live access. Archive is compare/export only.",
|
||||||
"analytics": "Analytics",
|
"analytics": "Analytics",
|
||||||
@@ -3080,7 +3105,7 @@
|
|||||||
"bans": "Bans",
|
"bans": "Bans",
|
||||||
"fullAdmin": "Full admin",
|
"fullAdmin": "Full admin",
|
||||||
"backToSite": "Back to site",
|
"backToSite": "Back to site",
|
||||||
"tickets": "Support desk",
|
"tickets": "Tickets",
|
||||||
"helpTickets": "Help-center tickets",
|
"helpTickets": "Help-center tickets",
|
||||||
"users": "Users",
|
"users": "Users",
|
||||||
"team": "Team"
|
"team": "Team"
|
||||||
|
|||||||
+26
-1
@@ -2565,7 +2565,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2870,6 +2871,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"radioRequests": {
|
||||||
|
|||||||
+26
-1
@@ -2565,7 +2565,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2870,6 +2871,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"radioRequests": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+29
-4
@@ -952,7 +952,7 @@
|
|||||||
"advertisements": "Pubblicità",
|
"advertisements": "Pubblicità",
|
||||||
"writeableBoxes": "Box modificabili",
|
"writeableBoxes": "Box modificabili",
|
||||||
"photos": "Foto",
|
"photos": "Foto",
|
||||||
"tickets": "Assistenza",
|
"tickets": "Ticket",
|
||||||
"helpTickets": "Ticket centro assistenza",
|
"helpTickets": "Ticket centro assistenza",
|
||||||
"events": "Eventi",
|
"events": "Eventi",
|
||||||
"polls": "Sondaggi",
|
"polls": "Sondaggi",
|
||||||
@@ -2812,12 +2812,13 @@
|
|||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
"errors": "Errori",
|
"errors": "Errori",
|
||||||
"alerts": "Alert",
|
"alerts": "Alert",
|
||||||
"toolsGroup": "Strumenti"
|
"toolsGroup": "Strumenti",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Eventi",
|
"events": "Eventi",
|
||||||
"eventsSubtitle": "Eventi hotel e tipi evento",
|
"eventsSubtitle": "Eventi hotel e tipi evento",
|
||||||
"tickets": "Assistenza",
|
"tickets": "Ticket",
|
||||||
"ticketsSubtitle": "Due code separate: supporto CMS e ticket help center pubblici",
|
"ticketsSubtitle": "Inbox unificata su supporto CMS e help center",
|
||||||
"staffAccess": "Gradi e permessi",
|
"staffAccess": "Gradi e permessi",
|
||||||
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
|
"staffAccessSubtitle": "Gradi staff e accesso live. L'archivio e solo confronto/export.",
|
||||||
"analytics": "Analitiche",
|
"analytics": "Analitiche",
|
||||||
@@ -3042,6 +3043,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"radioRequests": {
|
||||||
|
|||||||
+26
-1
@@ -2853,7 +2853,8 @@
|
|||||||
"devops": "DevOps",
|
"devops": "DevOps",
|
||||||
"errors": "Fouten",
|
"errors": "Fouten",
|
||||||
"alerts": "Meldingen",
|
"alerts": "Meldingen",
|
||||||
"toolsGroup": "Tools"
|
"toolsGroup": "Tools",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Evenementen",
|
"events": "Evenementen",
|
||||||
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
"eventsSubtitle": "Hotel-evenementen en evenementtypes",
|
||||||
@@ -3051,6 +3052,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"mod": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2593,7 +2593,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2898,6 +2899,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
+26
-1
@@ -2595,7 +2595,8 @@
|
|||||||
"errors": "Errors",
|
"errors": "Errors",
|
||||||
"alerts": "Alerts",
|
"alerts": "Alerts",
|
||||||
"toolsGroup": "Tools",
|
"toolsGroup": "Tools",
|
||||||
"helpTickets": "Help-center tickets"
|
"helpTickets": "Help-center tickets",
|
||||||
|
"ticketInbox": "Inbox"
|
||||||
},
|
},
|
||||||
"events": "Events",
|
"events": "Events",
|
||||||
"eventsSubtitle": "Hotel events and event types",
|
"eventsSubtitle": "Hotel events and event types",
|
||||||
@@ -2900,6 +2901,30 @@
|
|||||||
"title": "Online users",
|
"title": "Online users",
|
||||||
"subtitle": "Players currently connected",
|
"subtitle": "Players currently connected",
|
||||||
"connected": "{count, plural, one {# player connected} other {# players 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": {
|
"error": {
|
||||||
|
|||||||
Reference in new issue
Block a user