387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
import dynamic from "next/dynamic";
|
|
import { redirect } from "next/navigation";
|
|
import { CatalogTree } from "@/components/admin/catalog-tree";
|
|
import { Prisma } from "@/generated/prisma/client";
|
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { CatalogActions } from "./catalog-actions";
|
|
import { CatalogTable } from "./catalog-table";
|
|
import { CatalogViewToggle } from "./catalog-view-toggle";
|
|
|
|
const CatalogManagerDialog = dynamic(() =>
|
|
import("@/components/admin/catalog-manager/catalog-manager-dialog").then((m) => ({
|
|
default: m.CatalogManagerDialog,
|
|
})),
|
|
);
|
|
export default async function CatalogPagesPage({
|
|
params: paramsPromise,
|
|
searchParams,
|
|
}: {
|
|
params: Promise<{ locale: string }>;
|
|
searchParams: Promise<Record<string, string>>;
|
|
}) {
|
|
await paramsPromise;
|
|
const { session, permissions } = await getAdminContext();
|
|
if (!canAccess(permissions, PERMS.CATALOG_VIEW, session.user.rank)) {
|
|
redirect("/admin");
|
|
}
|
|
|
|
const canEdit = canAccess(permissions, PERMS.CATALOG_EDIT, session.user.rank);
|
|
|
|
const params = await searchParams;
|
|
const sp = new URLSearchParams(params);
|
|
const { search, perPage, page, sort, order } = parseListParams(sp);
|
|
const viewMode = sp.get("view") || "tree";
|
|
const catalogType = sp.get("catalog") || "normal";
|
|
|
|
// ── Builder Club mode ──────────────────────────────────────────
|
|
type TreeNodeShape = {
|
|
id: number;
|
|
caption: string;
|
|
parentId: number;
|
|
enabled: string;
|
|
visible: string;
|
|
iconImage: number;
|
|
iconColor: number;
|
|
pageLayout: string;
|
|
childCount: number;
|
|
itemCount: number;
|
|
};
|
|
|
|
if (catalogType === "bc") {
|
|
const [totalBcPages, totalBcItems, enabledBcPages] = await Promise.all([
|
|
prisma.catalogPagesBc.count(),
|
|
prisma.catalogItemsBc.count(),
|
|
prisma.catalogPagesBc.count({ where: { enabled: "1" } }),
|
|
]);
|
|
|
|
// ── BC tree view ──────────────────────────────────────────────
|
|
let bcRootPages: TreeNodeShape[] = [];
|
|
if (viewMode === "tree") {
|
|
const bcRootRaw = await prisma.catalogPagesBc.findMany({
|
|
where: { OR: [{ parentId: -1 }, { parentId: 0 }] },
|
|
orderBy: { orderNum: "asc" },
|
|
});
|
|
const bcRootIds = bcRootRaw.map((p) => p.id);
|
|
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
|
bcRootIds.length > 0
|
|
? prisma.catalogPagesBc.groupBy({
|
|
by: ["parentId"],
|
|
where: { parentId: { in: bcRootIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
bcRootIds.length > 0
|
|
? prisma.catalogItemsBc.groupBy({
|
|
by: ["pageId"],
|
|
where: { pageId: { in: bcRootIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
]);
|
|
const bcChildMap = Object.fromEntries(bcChildCounts.map((c) => [c.parentId, c._count]));
|
|
const bcItemMap = Object.fromEntries(bcItemCounts.map((c) => [c.pageId, c._count]));
|
|
bcRootPages = bcRootRaw.map((p) => ({
|
|
id: p.id,
|
|
caption: p.caption,
|
|
parentId: p.parentId,
|
|
enabled: String(p.enabled),
|
|
visible: String(p.visible),
|
|
iconImage: p.iconImage,
|
|
iconColor: p.iconColor,
|
|
pageLayout: p.pageLayout,
|
|
childCount: bcChildMap[p.id] ?? 0,
|
|
itemCount: bcItemMap[p.id] ?? 0,
|
|
}));
|
|
}
|
|
|
|
// ── BC table view ─────────────────────────────────────────────
|
|
let bcRows: {
|
|
id: number;
|
|
caption: string;
|
|
parentId: number;
|
|
parentCaption: string;
|
|
pageLayout: string;
|
|
enabled: string;
|
|
visible: string;
|
|
minRank: number;
|
|
orderNum: number;
|
|
itemCount: number;
|
|
childCount: number;
|
|
}[] = [];
|
|
let bcPagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
|
|
|
if (viewMode === "table") {
|
|
const parentFilter = sp.get("parent");
|
|
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
|
|
|
const where: Record<string, unknown> = {};
|
|
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
|
if (search) {
|
|
const pattern = { contains: search };
|
|
where.OR = [{ caption: pattern }];
|
|
}
|
|
if (hasParent) where.parentId = parentId;
|
|
|
|
// Prisma sort key is snake_case for BC model. Translate known keys.
|
|
const sortKeyMap: Record<string, string> = {
|
|
parentId: "parentId",
|
|
pageLayout: "pageLayout",
|
|
orderNum: "orderNum",
|
|
};
|
|
const sortKey = sort ? (sortKeyMap[sort] ?? sort) : "orderNum";
|
|
const orderBy = { [sortKey]: sort ? order : "asc" };
|
|
|
|
const [pages, total] = await Promise.all([
|
|
prisma.catalogPagesBc.findMany({
|
|
where,
|
|
orderBy,
|
|
skip: (page - 1) * perPage,
|
|
take: perPage,
|
|
}),
|
|
prisma.catalogPagesBc.count({ where }),
|
|
]);
|
|
|
|
bcPagination = calcPagination(total, page, perPage);
|
|
|
|
const pageIds = pages.map((p) => p.id);
|
|
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
|
|
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
|
pageIds.length > 0
|
|
? prisma.catalogPagesBc.groupBy({
|
|
by: ["parentId"],
|
|
where: { parentId: { in: pageIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
pageIds.length > 0
|
|
? prisma.catalogItemsBc.groupBy({
|
|
by: ["pageId"],
|
|
where: { pageId: { in: pageIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
parentIds.length > 0
|
|
? prisma.catalogPagesBc.findMany({
|
|
where: { id: { in: parentIds } },
|
|
select: { id: true, caption: true },
|
|
})
|
|
: [],
|
|
]);
|
|
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
|
|
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
|
|
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
|
|
|
|
bcRows = pages.map((p) => ({
|
|
id: p.id,
|
|
caption: p.caption,
|
|
parentId: p.parentId,
|
|
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
|
pageLayout: String(p.pageLayout),
|
|
enabled: String(p.enabled),
|
|
visible: String(p.visible),
|
|
minRank: 0, // BC pages have no min rank column
|
|
orderNum: p.orderNum,
|
|
itemCount: itemCountMap[p.id] ?? 0,
|
|
childCount: childCountMap[p.id] ?? 0,
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
|
|
<p className="text-muted-foreground">
|
|
{totalBcPages} BC pages ({enabledBcPages} enabled) · {totalBcItems} items
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
|
{canEdit && <CatalogActions catalogType="bc" />}
|
|
</div>
|
|
</div>
|
|
{viewMode === "tree" ? (
|
|
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
|
) : (
|
|
<CatalogTable data={{ rows: bcRows, ...bcPagination }} canEdit={canEdit} catalogType="bc" />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Normal catalog mode ────────────────────────────────────────
|
|
|
|
// Global stats (lightweight counts)
|
|
const [totalPages, totalItems, enabledPages] = await Promise.all([
|
|
prisma.catalogPages.count(),
|
|
prisma.catalogItems.count(),
|
|
prisma.catalogPages.count({ where: { enabled: "1" } }),
|
|
]);
|
|
|
|
// ── Tree view data ──────────────────────────────────────────────
|
|
let rootPages: TreeNodeShape[] = [];
|
|
if (viewMode === "tree") {
|
|
const rootPagesRaw = await prisma.catalogPages.findMany({
|
|
where: { parentId: -1 },
|
|
orderBy: { orderNum: "asc" },
|
|
});
|
|
const rootIds = rootPagesRaw.map((p) => p.id);
|
|
const [rootChildCounts, rootItemCounts] = await Promise.all([
|
|
rootIds.length > 0
|
|
? prisma.catalogPages.groupBy({
|
|
by: ["parentId"],
|
|
where: { parentId: { in: rootIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
rootIds.length > 0
|
|
? prisma.catalogItems.groupBy({
|
|
by: ["pageId"],
|
|
where: { pageId: { in: rootIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
]);
|
|
const rootChildMap = Object.fromEntries(rootChildCounts.map((c) => [c.parentId, c._count]));
|
|
const rootItemMap = Object.fromEntries(rootItemCounts.map((c) => [c.pageId, c._count]));
|
|
rootPages = rootPagesRaw.map((p) => ({
|
|
id: p.id,
|
|
caption: p.caption,
|
|
parentId: p.parentId,
|
|
enabled: String(p.enabled),
|
|
visible: String(p.visible),
|
|
iconImage: p.iconImage,
|
|
iconColor: p.iconColor,
|
|
pageLayout: p.pageLayout,
|
|
childCount: rootChildMap[p.id] ?? 0,
|
|
itemCount: rootItemMap[p.id] ?? 0,
|
|
}));
|
|
}
|
|
|
|
// ── Table view data (skip if tree) ────────────────────────────
|
|
let rows: {
|
|
id: number;
|
|
caption: string;
|
|
parentId: number;
|
|
parentCaption: string;
|
|
pageLayout: string;
|
|
enabled: string;
|
|
visible: string;
|
|
minRank: number;
|
|
orderNum: number;
|
|
itemCount: number;
|
|
childCount: number;
|
|
}[] = [];
|
|
let pagination = { total: 0, page: 1, perPage, lastPage: 0 };
|
|
|
|
if (viewMode === "table") {
|
|
const parentFilter = sp.get("parent");
|
|
const parentId = parentFilter !== null ? parseInt(parentFilter, 10) : undefined;
|
|
|
|
const where: Record<string, unknown> = {};
|
|
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
|
if (search) {
|
|
const searchPattern = `%${search}%`;
|
|
const parentClause = hasParent ? Prisma.sql`AND cp.parentId = ${parentId}` : Prisma.empty;
|
|
|
|
const matchedIds = await prisma.$queryRaw<{ id: number }[]>(Prisma.sql`
|
|
SELECT DISTINCT cp.id FROM catalog_pages cp
|
|
LEFT JOIN catalog_items ci ON ci.pageId = cp.id
|
|
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.itemIds, ';', ','))
|
|
WHERE (cp.caption LIKE ${searchPattern}
|
|
OR cp.pageLayout LIKE ${searchPattern}
|
|
OR ci.catalogName LIKE ${searchPattern}
|
|
OR ib.public_name LIKE ${searchPattern}
|
|
OR ib.item_name LIKE ${searchPattern})
|
|
${parentClause}
|
|
`);
|
|
where.id = { in: matchedIds.map((r) => r.id) };
|
|
} else if (hasParent) {
|
|
where.parentId = parentId;
|
|
}
|
|
|
|
const orderBy = sort ? { [sort]: order } : { orderNum: "asc" as const };
|
|
|
|
const [pages, total] = await Promise.all([
|
|
prisma.catalogPages.findMany({
|
|
where,
|
|
orderBy,
|
|
skip: (page - 1) * perPage,
|
|
take: perPage,
|
|
}),
|
|
prisma.catalogPages.count({ where }),
|
|
]);
|
|
|
|
pagination = calcPagination(total, page, perPage);
|
|
|
|
// Count children and items per page, and resolve parent names in parallel
|
|
const pageIds = pages.map((p) => p.id);
|
|
const parentIds = [...new Set(pages.map((p) => p.parentId).filter((id) => id > 0))];
|
|
const [childCounts, itemCounts, parentPages] = await Promise.all([
|
|
pageIds.length > 0
|
|
? prisma.catalogPages.groupBy({
|
|
by: ["parentId"],
|
|
where: { parentId: { in: pageIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
pageIds.length > 0
|
|
? prisma.catalogItems.groupBy({
|
|
by: ["pageId"],
|
|
where: { pageId: { in: pageIds } },
|
|
_count: true,
|
|
})
|
|
: [],
|
|
parentIds.length > 0
|
|
? prisma.catalogPages.findMany({
|
|
where: { id: { in: parentIds } },
|
|
select: { id: true, caption: true },
|
|
})
|
|
: [],
|
|
]);
|
|
const childCountMap = Object.fromEntries(childCounts.map((c) => [c.parentId, c._count]));
|
|
const itemCountMap = Object.fromEntries(itemCounts.map((c) => [c.pageId, c._count]));
|
|
const parentNameMap = Object.fromEntries(parentPages.map((p) => [p.id, p.caption]));
|
|
|
|
rows = pages.map((p) => ({
|
|
id: p.id,
|
|
caption: p.caption,
|
|
parentId: p.parentId,
|
|
parentCaption: p.parentId === -1 ? "Root" : (parentNameMap[p.parentId] ?? `#${p.parentId}`),
|
|
pageLayout: p.pageLayout,
|
|
enabled: p.enabled,
|
|
visible: p.visible,
|
|
minRank: p.minRank,
|
|
orderNum: p.orderNum,
|
|
itemCount: itemCountMap[p.id] ?? 0,
|
|
childCount: childCountMap[p.id] ?? 0,
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-3xl font-bold tracking-tight">Catalog Pages</h1>
|
|
<p className="text-muted-foreground">
|
|
{totalPages} pages ({enabledPages} enabled) · {totalItems} items
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
|
{canEdit && <CatalogManagerDialog />}
|
|
{canEdit && <CatalogActions />}
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === "tree" ? (
|
|
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
|
) : (
|
|
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
|
)}
|
|
</div>
|
|
);
|
|
}
|