diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts
index cbce816b..80b25037 100644
--- a/src/actions/catalog.ts
+++ b/src/actions/catalog.ts
@@ -347,3 +347,181 @@ export async function createAutoCategory(input: {
return { ok: true as const, data: { pageId } };
});
}
+
+const MAX_ORGANIZE_GROUPS = 50;
+const MAX_ORGANIZE_ITEMS = 1000;
+
+export interface OrganizeImportGroup {
+ caption: string;
+ pageLayout: string;
+ iconImage: number;
+ items: Array<{
+ itemId: number;
+ catalogItemId: number | null;
+ credits?: number;
+ points?: number;
+ pointsType?: number;
+ amount?: number;
+ }>;
+}
+
+/**
+ * Turn approved import suggestions into real catalog pages in one export
+ * snapshot. Offers that already exist inside the imported-furniture tree are
+ * moved to the freshly created page; brand-new furniture gets a new offer.
+ * Returns per-page move/add counts so the UI can summarize what happened.
+ */
+export async function organizeImportFurni(input: {
+ parentId: number;
+ groups: OrganizeImportGroup[];
+}): Promise<
+ ActionResult<{
+ created: Array<{
+ pageId: number;
+ caption: string;
+ moved: number;
+ added: number;
+ }>;
+ }>
+> {
+ const staff = await requirePermission(PERMS.CATALOG_EDIT);
+
+ const groups = input.groups.filter((g) => (g.items?.length ?? 0) > 0);
+ if (groups.length === 0) {
+ return { ok: false as const, error: "No groups selected" };
+ }
+ if (groups.length > MAX_ORGANIZE_GROUPS) {
+ return {
+ ok: false as const,
+ error: `Max ${MAX_ORGANIZE_GROUPS} groups per run`,
+ };
+ }
+ const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
+ if (totalItems > MAX_ORGANIZE_ITEMS) {
+ return {
+ ok: false as const,
+ error: `Max ${MAX_ORGANIZE_ITEMS} items per run`,
+ };
+ }
+
+ return await withCatalogExport(async () => {
+ const { getImportedCategoryPageIds } = await import(
+ "@/features/catalog/server/import-pages"
+ );
+ const importPageIds = new Set(await getImportedCategoryPageIds());
+
+ const created: Array<{
+ pageId: number;
+ caption: string;
+ moved: number;
+ added: number;
+ }> = [];
+ let totalMoved = 0;
+ let totalAdded = 0;
+
+ for (const group of groups) {
+ const pageId = Number(
+ await createPageCommand("normal", {
+ caption: group.caption,
+ parentId: input.parentId,
+ pageLayout: group.pageLayout || "default_3x3",
+ captionSave: group.caption.slice(0, 25),
+ iconImage: group.iconImage ?? 0,
+ iconColor: 0,
+ minRank: 1,
+ orderNum: 0,
+ visible: "1",
+ enabled: "1",
+ clubOnly: "0",
+ vipOnly: "0",
+ pageHeadline: "",
+ pageTeaser: "",
+ includes: "",
+ }),
+ );
+
+ const baseIds = [...new Set(group.items.map((r) => r.itemId))];
+ const bases = await db
+ .select({
+ id: ItemsBase.id,
+ publicName: ItemsBase.publicName,
+ itemName: ItemsBase.itemName,
+ })
+ .from(ItemsBase)
+ .where(inArray(ItemsBase.id, baseIds));
+ const baseMap = new Map(bases.map((b) => [b.id, b]));
+
+ let nextOrder = 1;
+ let moved = 0;
+ let added = 0;
+ for (const row of group.items) {
+ const base = baseMap.get(row.itemId);
+ if (!base) continue;
+ const catalogName =
+ base.publicName || base.itemName || String(row.itemId);
+
+ if (row.catalogItemId) {
+ // Verify the offer currently sits inside the import tree before
+ // moving it, so we never hijack an unrelated catalog offer.
+ const [host] = (await db.execute(sql`
+ SELECT page_id FROM catalog_items WHERE id = ${row.catalogItemId}
+ `)) as unknown as [Array<{ page_id: number }>, unknown];
+ const hostPageId = Number(host[0]?.page_id ?? -1);
+ if (!importPageIds.has(hostPageId)) {
+ console.error(
+ `[organizeImportFurni] skipping offer #${row.catalogItemId} on non-import page #${hostPageId}`,
+ );
+ continue;
+ }
+ await db.execute(sql`
+ UPDATE catalog_items
+ SET page_id = ${pageId}, order_number = ${nextOrder}, catalog_name = ${catalogName}
+ WHERE id = ${row.catalogItemId}
+ `);
+ moved++;
+ } else {
+ await insertCatalogItemRow({
+ pageId,
+ itemIds: String(row.itemId),
+ catalogName,
+ costCredits: row.credits ?? 0,
+ costPoints: row.points ?? 0,
+ pointsType: row.pointsType ?? 0,
+ amount: row.amount ?? 1,
+ limitedSells: 0,
+ limitedStack: 0,
+ orderNumber: nextOrder,
+ offerId: -1,
+ songId: 0,
+ haveOffer: "1",
+ clubOnly: "0",
+ extradata: "",
+ });
+ added++;
+ }
+ nextOrder++;
+ }
+
+ await logStaffActivity({
+ staffId: staff.id,
+ action: "catalog_page_create",
+ description: `Organized import into page #${pageId} "${group.caption}" (${moved} moved, ${added} added)`,
+ targetType: "catalog_page",
+ targetId: pageId,
+ });
+ created.push({ pageId, caption: group.caption, moved, added });
+ totalMoved += moved;
+ totalAdded += added;
+ }
+
+ await sendCatalogUpdate();
+ await logStaffActivity({
+ staffId: staff.id,
+ action: "catalog_import_organize",
+ description: `Organized ${created.length} import page(s): ${totalMoved} offer(s) moved, ${totalAdded} offer(s) added`,
+ targetType: "catalog",
+ });
+ revalidatePath("/admin/catalog");
+ return { ok: true as const, data: { created } };
+ });
+}
diff --git a/src/app/admin/studio/studio-layout-client.tsx b/src/app/admin/studio/studio-layout-client.tsx
index 9a65d237..ef6183cc 100644
--- a/src/app/admin/studio/studio-layout-client.tsx
+++ b/src/app/admin/studio/studio-layout-client.tsx
@@ -7,6 +7,7 @@ import {
ClipboardCheck,
Copy,
Dumbbell,
+ FolderTree,
Image as ImageIcon,
type LucideIcon,
Puzzle,
@@ -18,6 +19,7 @@ import {
import { usePathname } from "next/navigation";
import { useState } from "react";
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
+import { OrganizeImportsDialog } from "@/components/admin/studio/organize-imports-dialog";
import Link from "@/components/link";
import {
Popover,
@@ -113,6 +115,7 @@ export function StudioLayoutClient({
children: React.ReactNode;
}) {
const pathname = usePathname() ?? "";
+ const [organizeOpen, setOrganizeOpen] = useState(false);
return (