feat(studio): add organize-imports tool with suggestion review and approval
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:
1 parent
f5c2c05f6e
commit
01a85ebcd0
9 files changed
+1230
-15
No files matched your search
@@ -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 } };
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user