547 lines
16 KiB
TypeScript
547 lines
16 KiB
TypeScript
import {
|
|
and,
|
|
asc,
|
|
count,
|
|
desc,
|
|
eq,
|
|
inArray,
|
|
like,
|
|
or,
|
|
type SQL,
|
|
sql,
|
|
} from "drizzle-orm";
|
|
import type { AnyMySqlColumn } from "drizzle-orm/mysql-core";
|
|
import { Store } from "lucide-react";
|
|
import { redirect } from "next/navigation";
|
|
import { getTranslations } from "next-intl/server";
|
|
import { AdminPageShell } from "@/components/admin/admin-page-shell";
|
|
import { CatalogTree } from "@/components/admin/catalog-tree";
|
|
import Link from "@/components/link";
|
|
import { CatalogSearch } from "@/features/catalog/components/catalog-search";
|
|
import { CatalogSyncStatus } from "@/features/catalog/components/catalog-sync-status";
|
|
import { CatalogPackagesDialog } from "@/features/catalog/components/packages/catalog-packages-dialog";
|
|
import { calcPagination, parseListParams } from "@/lib/admin-helpers";
|
|
import {
|
|
CatalogItems,
|
|
CatalogItemsBc,
|
|
CatalogPages,
|
|
CatalogPagesBc,
|
|
db,
|
|
} from "@/lib/db";
|
|
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
|
|
import { getCatalogItemCounts } from "@/lib/services/catalog-tree";
|
|
import { CatalogActions } from "./catalog-actions";
|
|
import { CatalogManagerDialog } from "./catalog-manager-dialog-wrapper";
|
|
import { CatalogTable } from "./catalog-table";
|
|
import { CatalogViewToggle } from "./catalog-view-toggle";
|
|
|
|
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
|
|
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
|
|
return rows ?? [];
|
|
}
|
|
|
|
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 t = await getTranslations("pages.admin.catalog");
|
|
|
|
const params = await searchParams;
|
|
const sp = new URLSearchParams(params);
|
|
const { search, perPage, page, sort, order } = parseListParams(sp);
|
|
const requestedView = sp.get("view");
|
|
const viewMode = requestedView === "table" ? "table" : "tree";
|
|
const catalogType = sp.get("catalog") === "bc" ? "bc" : "normal";
|
|
|
|
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 [totalBcPagesRows, totalBcItemsRows, enabledBcPagesRows] =
|
|
await Promise.all([
|
|
db.select({ total: count() }).from(CatalogPagesBc),
|
|
db.select({ total: count() }).from(CatalogItemsBc),
|
|
db
|
|
.select({ total: count() })
|
|
.from(CatalogPagesBc)
|
|
.where(eq(CatalogPagesBc.enabled, "1")),
|
|
]);
|
|
const totalBcPages = Number(totalBcPagesRows[0]?.total ?? 0);
|
|
const totalBcItems = Number(totalBcItemsRows[0]?.total ?? 0);
|
|
const enabledBcPages = Number(enabledBcPagesRows[0]?.total ?? 0);
|
|
|
|
let bcRootPages: TreeNodeShape[] = [];
|
|
if (viewMode === "tree") {
|
|
const bcRootRaw = await db
|
|
.select()
|
|
.from(CatalogPagesBc)
|
|
.where(
|
|
or(eq(CatalogPagesBc.parentId, -1), eq(CatalogPagesBc.parentId, 0)),
|
|
)
|
|
.orderBy(asc(CatalogPagesBc.orderNum), asc(CatalogPagesBc.id));
|
|
const bcRootIds = bcRootRaw.map((p) => p.id);
|
|
const [bcChildCounts, bcItemCounts] = await Promise.all([
|
|
bcRootIds.length > 0
|
|
? db
|
|
.select({
|
|
parentId: CatalogPagesBc.parentId,
|
|
total: count(),
|
|
})
|
|
.from(CatalogPagesBc)
|
|
.where(inArray(CatalogPagesBc.parentId, bcRootIds))
|
|
.groupBy(CatalogPagesBc.parentId)
|
|
: [],
|
|
bcRootIds.length > 0
|
|
? db
|
|
.select({
|
|
pageId: CatalogItemsBc.pageId,
|
|
total: count(),
|
|
})
|
|
.from(CatalogItemsBc)
|
|
.where(inArray(CatalogItemsBc.pageId, bcRootIds))
|
|
.groupBy(CatalogItemsBc.pageId)
|
|
: [],
|
|
]);
|
|
const bcChildMap = Object.fromEntries(
|
|
bcChildCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
);
|
|
const bcItemMap = Object.fromEntries(
|
|
bcItemCounts.map((c) => [c.pageId, Number(c.total)]),
|
|
);
|
|
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,
|
|
}));
|
|
}
|
|
|
|
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 conditions: SQL[] = [];
|
|
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
|
if (search) {
|
|
conditions.push(like(CatalogPagesBc.caption, `%${search}%`));
|
|
}
|
|
if (hasParent) conditions.push(eq(CatalogPagesBc.parentId, parentId));
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
|
|
const sortKeyMap: Record<string, AnyMySqlColumn> = {
|
|
parentId: CatalogPagesBc.parentId,
|
|
pageLayout: CatalogPagesBc.pageLayout,
|
|
orderNum: CatalogPagesBc.orderNum,
|
|
caption: CatalogPagesBc.caption,
|
|
id: CatalogPagesBc.id,
|
|
};
|
|
const sortCol = sort
|
|
? (sortKeyMap[sort] ?? CatalogPagesBc.orderNum)
|
|
: CatalogPagesBc.orderNum;
|
|
const orderBy =
|
|
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
|
|
|
|
const [pages, totalRows] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(CatalogPagesBc)
|
|
.where(where)
|
|
.orderBy(orderBy)
|
|
.limit(perPage)
|
|
.offset((page - 1) * perPage),
|
|
db.select({ total: count() }).from(CatalogPagesBc).where(where),
|
|
]);
|
|
const total = Number(totalRows[0]?.total ?? 0);
|
|
|
|
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
|
|
? db
|
|
.select({
|
|
parentId: CatalogPagesBc.parentId,
|
|
total: count(),
|
|
})
|
|
.from(CatalogPagesBc)
|
|
.where(inArray(CatalogPagesBc.parentId, pageIds))
|
|
.groupBy(CatalogPagesBc.parentId)
|
|
: [],
|
|
pageIds.length > 0
|
|
? db
|
|
.select({
|
|
pageId: CatalogItemsBc.pageId,
|
|
total: count(),
|
|
})
|
|
.from(CatalogItemsBc)
|
|
.where(inArray(CatalogItemsBc.pageId, pageIds))
|
|
.groupBy(CatalogItemsBc.pageId)
|
|
: [],
|
|
parentIds.length > 0
|
|
? db
|
|
.select({
|
|
id: CatalogPagesBc.id,
|
|
caption: CatalogPagesBc.caption,
|
|
})
|
|
.from(CatalogPagesBc)
|
|
.where(inArray(CatalogPagesBc.id, parentIds))
|
|
: [],
|
|
]);
|
|
const childCountMap = Object.fromEntries(
|
|
childCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
);
|
|
const itemCountMap = Object.fromEntries(
|
|
itemCounts.map((c) => [c.pageId, Number(c.total)]),
|
|
);
|
|
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,
|
|
orderNum: p.orderNum,
|
|
itemCount: itemCountMap[p.id] ?? 0,
|
|
childCount: childCountMap[p.id] ?? 0,
|
|
}));
|
|
}
|
|
|
|
return (
|
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
|
<div className="mb-4 space-y-3">
|
|
<CatalogSyncStatus canEdit={canEdit} />
|
|
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
|
</div>
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-muted-foreground">
|
|
{totalBcPages} BC pages ({enabledBcPages} enabled) ·{" "}
|
|
{totalBcItems} items
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<CatalogViewToggle currentView={viewMode} catalogType="bc" />
|
|
{canEdit && (
|
|
<CatalogManagerDialog
|
|
catalogType="bc"
|
|
canEdit={canEdit}
|
|
totals={{
|
|
totalPages: totalBcPages,
|
|
enabledPages: enabledBcPages,
|
|
totalItems: totalBcItems,
|
|
}}
|
|
initialRoots={bcRootPages.map((p) => ({
|
|
id: p.id,
|
|
caption: p.caption,
|
|
iconImage: p.iconImage,
|
|
enabled: p.enabled,
|
|
orderNum: 0,
|
|
}))}
|
|
/>
|
|
)}
|
|
{canEdit && (
|
|
<CatalogActions
|
|
catalogType="bc"
|
|
initialParentId={sp.get("newPageParent")}
|
|
/>
|
|
)}
|
|
<Link
|
|
className="btn btn-outline"
|
|
href="/admin/catalog/maintenance"
|
|
>
|
|
{t("workspace.diagnostics")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
{viewMode === "tree" ? (
|
|
<CatalogTree rootPages={bcRootPages} catalogType="bc" />
|
|
) : (
|
|
<CatalogTable
|
|
data={{ rows: bcRows, ...bcPagination }}
|
|
canEdit={canEdit}
|
|
catalogType="bc"
|
|
/>
|
|
)}
|
|
</div>
|
|
</AdminPageShell>
|
|
);
|
|
}
|
|
|
|
const [totalPagesRows, totalItemsRows, enabledPagesRows] = await Promise.all([
|
|
db.select({ total: count() }).from(CatalogPages),
|
|
db.select({ total: count() }).from(CatalogItems),
|
|
db
|
|
.select({ total: count() })
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.enabled, "1")),
|
|
]);
|
|
const totalPages = Number(totalPagesRows[0]?.total ?? 0);
|
|
const totalItems = Number(totalItemsRows[0]?.total ?? 0);
|
|
const enabledPages = Number(enabledPagesRows[0]?.total ?? 0);
|
|
|
|
const rootPagesRaw = await db
|
|
.select()
|
|
.from(CatalogPages)
|
|
.where(or(eq(CatalogPages.parentId, -1), eq(CatalogPages.parentId, 0)))
|
|
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
|
|
const rootIds = rootPagesRaw.map((p) => p.id);
|
|
const [rootChildCounts, rootItemCountMap] = await Promise.all([
|
|
rootIds.length > 0
|
|
? db
|
|
.select({
|
|
parentId: CatalogPages.parentId,
|
|
total: count(),
|
|
})
|
|
.from(CatalogPages)
|
|
.where(inArray(CatalogPages.parentId, rootIds))
|
|
.groupBy(CatalogPages.parentId)
|
|
: [],
|
|
getCatalogItemCounts(rootIds),
|
|
]);
|
|
const rootChildMap = Object.fromEntries(
|
|
rootChildCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
);
|
|
const rootItemMap = Object.fromEntries(rootItemCountMap);
|
|
const rootPages: TreeNodeShape[] = 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,
|
|
}));
|
|
|
|
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 conditions: SQL[] = [];
|
|
const hasParent = parentId !== undefined && !Number.isNaN(parentId);
|
|
if (search) {
|
|
const searchPattern = `%${search}%`;
|
|
const parentClause = hasParent
|
|
? sql`AND cp.parent_id = ${parentId}`
|
|
: sql``;
|
|
|
|
const matchedIds = await rawRows<{ id: number }>(sql`
|
|
SELECT DISTINCT cp.id FROM catalog_pages cp
|
|
LEFT JOIN catalog_items ci ON CAST(ci.page_id AS CHAR) = CAST(cp.id AS CHAR)
|
|
LEFT JOIN items_base ib ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
|
|
WHERE (cp.caption LIKE ${searchPattern}
|
|
OR cp.page_layout LIKE ${searchPattern}
|
|
OR ci.catalog_name LIKE ${searchPattern}
|
|
OR ib.public_name LIKE ${searchPattern}
|
|
OR ib.item_name LIKE ${searchPattern})
|
|
${parentClause}
|
|
`);
|
|
const ids = matchedIds.map((r) => r.id);
|
|
if (ids.length > 0) conditions.push(inArray(CatalogPages.id, ids));
|
|
else conditions.push(sql`1 = 0`);
|
|
} else if (hasParent) {
|
|
conditions.push(eq(CatalogPages.parentId, parentId));
|
|
}
|
|
|
|
const where = conditions.length > 0 ? and(...conditions) : undefined;
|
|
const sortColMap: Record<string, AnyMySqlColumn> = {
|
|
caption: CatalogPages.caption,
|
|
parentId: CatalogPages.parentId,
|
|
pageLayout: CatalogPages.pageLayout,
|
|
enabled: CatalogPages.enabled,
|
|
orderNum: CatalogPages.orderNum,
|
|
id: CatalogPages.id,
|
|
minRank: CatalogPages.minRank,
|
|
};
|
|
const sortCol = sort
|
|
? (sortColMap[sort] ?? CatalogPages.orderNum)
|
|
: CatalogPages.orderNum;
|
|
const orderBy =
|
|
(sort ? order : "asc") === "desc" ? desc(sortCol) : asc(sortCol);
|
|
|
|
const [pages, totalRows] = await Promise.all([
|
|
db
|
|
.select()
|
|
.from(CatalogPages)
|
|
.where(where)
|
|
.orderBy(orderBy)
|
|
.limit(perPage)
|
|
.offset((page - 1) * perPage),
|
|
db.select({ total: count() }).from(CatalogPages).where(where),
|
|
]);
|
|
const total = Number(totalRows[0]?.total ?? 0);
|
|
|
|
pagination = 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, itemCountMapRaw, parentPages] = await Promise.all([
|
|
pageIds.length > 0
|
|
? db
|
|
.select({
|
|
parentId: CatalogPages.parentId,
|
|
total: count(),
|
|
})
|
|
.from(CatalogPages)
|
|
.where(inArray(CatalogPages.parentId, pageIds))
|
|
.groupBy(CatalogPages.parentId)
|
|
: [],
|
|
getCatalogItemCounts(pageIds),
|
|
parentIds.length > 0
|
|
? db
|
|
.select({ id: CatalogPages.id, caption: CatalogPages.caption })
|
|
.from(CatalogPages)
|
|
.where(inArray(CatalogPages.id, parentIds))
|
|
: [],
|
|
]);
|
|
const childCountMap = Object.fromEntries(
|
|
childCounts.map((c) => [c.parentId, Number(c.total)]),
|
|
);
|
|
const itemCountMap = Object.fromEntries(itemCountMapRaw);
|
|
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 (
|
|
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
|
<div className="mb-4 space-y-3">
|
|
<CatalogSyncStatus canEdit={canEdit} />
|
|
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
|
</div>
|
|
<div className="space-y-6">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-muted-foreground">
|
|
{totalPages} pages ({enabledPages} enabled) · {totalItems}{" "}
|
|
items
|
|
</p>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<CatalogViewToggle currentView={viewMode} catalogType="normal" />
|
|
<CatalogPackagesDialog canEdit={canEdit} />
|
|
{canEdit && (
|
|
<CatalogManagerDialog
|
|
catalogType="normal"
|
|
canEdit={canEdit}
|
|
totals={{
|
|
totalPages,
|
|
enabledPages,
|
|
totalItems,
|
|
}}
|
|
initialRoots={rootPages.map((p) => ({
|
|
id: p.id,
|
|
caption: p.caption,
|
|
iconImage: p.iconImage,
|
|
enabled: p.enabled,
|
|
orderNum: 0,
|
|
}))}
|
|
/>
|
|
)}
|
|
{canEdit && (
|
|
<CatalogActions initialParentId={sp.get("newPageParent")} />
|
|
)}
|
|
<Link className="btn btn-outline" href="/admin/catalog/maintenance">
|
|
{t("workspace.diagnostics")}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{viewMode === "tree" ? (
|
|
<CatalogTree rootPages={rootPages} catalogType="normal" />
|
|
) : (
|
|
<CatalogTable data={{ rows, ...pagination }} canEdit={canEdit} />
|
|
)}
|
|
</div>
|
|
</AdminPageShell>
|
|
);
|
|
}
|