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:
openhands
2026-09-11 19:38:18 +02:00
parent f5c2c05f6e
commit 01a85ebcd0
9 changed files with 1230 additions and 15 deletions
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
import {
buildSortOrders,
groupSuggestedCategories,
type SortStrategy,
type SugFurni,
type SugFurniEntry,
sortCatalogItems,
suggestCatalogIcon,
suggestCatalogLayout,
@@ -227,3 +229,64 @@ describe("suggestCatalogIcon", () => {
expect(a).toBe(b);
});
});
describe("groupSuggestedCategories", () => {
function entry(
id: number,
itemName?: string,
interactionType?: string,
): SugFurniEntry {
return { id, itemName, interactionType };
}
it("returns an empty list for no input", () => {
expect(groupSuggestedCategories([])).toEqual([]);
});
it("groups furniture by shared label, largest group first", () => {
const groups = groupSuggestedCategories([
entry(1, "chair_wooden"),
entry(2, "chair_oak"),
entry(3, "sofa_polyfon"),
entry(4, "chair_polyfon"),
]);
expect(groups.map((g) => g.name)).toEqual(["Chairs", "Sofas"]);
expect(groups[0]?.items.map((i) => i.id)).toEqual([1, 2, 4]);
expect(groups[1]?.items.map((i) => i.id)).toEqual([3]);
});
it("sorts by size then alphabetically on ties", () => {
const groups = groupSuggestedCategories([
entry(1, "sofa_polyfon"),
entry(2, "chair_oak"),
]);
expect(groups.map((g) => g.name)).toEqual(["Chairs", "Sofas"]);
});
it("falls back deterministically and drops nothing", () => {
const groups = groupSuggestedCategories([
entry(1, "sofa_polyfon"),
entry(2, ""),
entry(3, ""),
]);
expect(groups.map((g) => g.name)).toEqual(["Other Furni", "Sofas"]);
const leftover = groups.find((g) => g.name === "Other Furni");
expect(leftover?.items.map((i) => i.id)).toEqual([2, 3]);
expect(leftover).not.toBeNull();
});
it("suggests a plausible icon and layout per group", () => {
const groups = groupSuggestedCategories([entry(1, "chair_oak")]);
expect(groups[0]).toBeDefined();
expect(groups[0]?.icon).toBeGreaterThan(0);
});
it("uses the same label as the batch name suggestion", () => {
const group = groupSuggestedCategories([
entry(1, "trophy_cup", "trophy"),
entry(2, "trophy_crown", "trophy"),
entry(3, "chair_oak"),
])[0];
expect(group?.name).toBe("Trophies");
});
});