feat(studio): add organize-imports tool with suggestion review and approval
CI / check (push) Successful in 2m22s
CI / deploy (push) Successful in 1m34s
CI / publish-container (push) Successful in 46s

Adds a Studio 'Organize imports' dialog that groups recently imported
furniture and furniture already sitting in the auto-created import
pages into suggested catalog categories.  Each group is presented with
its suggested name, icon, and layout which can be overridden before
approval; approved groups are turned into real catalog pages in a
single atomic export run.  Offers already inside the import subtree are
moved to the new pages; brand-new furniture gets a fresh offer.

- groupSuggestedCategories: generic pure helper reusing the same
  per-item label heuristic that drives suggestCategoryName; items with
  no label land in a 'Other Furni' remainder bucket
- GET /api/admin/import/organize: returns items from the imported
  furniture tree (catalog_items JOIN items_base via page id set from
  the imported-furniture root) union audit-logged but not-yet-placed
  recent imports; marks alreadyPlaced vs new
- organizeImportFurni server action: validates import-page membership
  before moving any offer, creates pages + inserts/moves items in one
  withCatalogExport snapshot, logs activity
- OrganizeImportsDialog: full-featured Studio dialog with price panel
  (applies to new offers only), parent select, per-group approval,
  editable name/icon/layout with suggestion reset chips, source tags
- import-pages.ts server helper: locates the imported-furniture tree
- Studio nav: 'Organize imports' button with FolderTree icon,
  gated on CATALOG_EDIT, wired next to the catalog manager
- en + nl translations for organizeImports.* keys with ICU plurals
- groupSuggestedCategories unit tests (deterministic grouping,
  remainder handling, size+alpha ordering, label consistency)
This commit is contained in:
openhands committed 2026-09-11 19:38:18 +02:00
1 parent f5c2c05f6e
commit 01a85ebcd0
9 files changed
+1230 -15

No files matched your search

+178
View File
@@ -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 } };
});
}