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

@@ -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");
});
});
+65 -10
View File
@@ -128,7 +128,7 @@ function layoutFromSignals(params: {
/** Layout suggestion using base-item fields directly (for the auto-category wizard). */
export function suggestCatalogLayoutFromBaseItems(
items: Array<{ type?: string; interactionType?: string }>,
items: Array<{ type?: string | null; interactionType?: string | null }>,
): LayoutSuggestion | null {
if (items.length === 0) return null;
const interactions = new Set(
@@ -264,6 +264,24 @@ function pluralizeLabel(token: string): string {
return `${cap}s`;
}
/**
* Per-item category label used by both the batch name suggestion and the
* import grouping. Interaction labels win over classname tokens; falls back to
* the classname token when nothing stronger is detected.
*/
function unitLabel(item: SugFurni): string | null {
const inter = item.interactionType?.toLowerCase();
let label: string | undefined;
if (inter && inter !== "default") {
label = INTERACTION_LABELS[inter];
}
if (!label) {
const token = classToken(item);
if (token) label = pluralizeLabel(token);
}
return label ?? null;
}
/**
* Best-effort English category label derived from a batch of base items.
* Never blocks — returns null when it can't determine anything useful.
@@ -273,15 +291,7 @@ export function suggestCategoryName(furni: SugFurni[]): string | null {
const labelCounts = new Map<string, number>();
for (const f of furni) {
const inter = f.interactionType?.toLowerCase();
let label: string | undefined;
if (inter && inter !== "default") {
label = INTERACTION_LABELS[inter];
}
if (!label) {
const token = classToken(f);
if (token) label = pluralizeLabel(token);
}
const label = unitLabel(f);
if (label) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
}
@@ -328,3 +338,48 @@ export function suggestCatalogIcon(furni: SugFurni[]): number {
if (!name) return 0;
return CATEGORY_ICON_MAP[name] ?? 1;
}
// ── Import organization grouping ─────────────────────────────
export type SugFurniEntry = SugFurni & { id: number };
export interface SuggestedFurniGroup<T extends SugFurniEntry = SugFurniEntry> {
name: string;
icon: number;
layout: LayoutSuggestion | null;
items: T[];
}
const REMAINDER_LABEL = "Other Furni";
/**
* Group furniture into suggested catalog categories. Every item keeps its
* deterministic per-item label, so the same logic drives the batch name
* suggestion and the grouping. Furniture that can't be labeled is collected
* into a single remainder group so nothing is ever dropped.
* Groups are ordered by size (largest first) and alphabetically on ties.
*/
export function groupSuggestedCategories<T extends SugFurniEntry>(
entries: T[],
): SuggestedFurniGroup<T>[] {
const buckets = new Map<string, T[]>();
for (const entry of entries) {
const key = unitLabel(entry) ?? REMAINDER_LABEL;
buckets.set(key, [...(buckets.get(key) ?? []), entry]);
}
const groups: SuggestedFurniGroup<T>[] = [];
for (const [name, items] of buckets) {
const sortedItems = [...items].sort((a, b) => a.id - b.id);
groups.push({
name,
icon: CATEGORY_ICON_MAP[name] ?? 1,
layout: suggestCatalogLayoutFromBaseItems(sortedItems),
items: sortedItems,
});
}
return groups.sort(
(a, b) => b.items.length - a.items.length || a.name.localeCompare(b.name),
);
}
@@ -0,0 +1,47 @@
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
/**
* Locate the auto-created "Imported Furniture" tree (caption_save
* 'imported_furni' root) and every category sub-page beneath it. These are the
* pages the import pipeline fills without review, so the organize-import tool
* knows exactly which furniture is "inbox" work.
*/
export async function getImportedCategoryPageIds(): Promise<number[]> {
const [rows] = (await db.execute(sql`
SELECT id FROM catalog_pages
WHERE caption_save = 'imported_furni'
AND parent_id IN (-1, 0)
LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown];
const rootId = Number(rows[0]?.id ?? 0);
if (!rootId) return [];
const [childRows] = (await db.execute(sql`
SELECT id FROM catalog_pages WHERE parent_id = ${rootId}
`)) as unknown as [Array<{ id: number }>, unknown];
const children = childRows.map((r) => Number(r.id));
const pageIds = [rootId, ...children];
if (children.length > 0) {
const [grandRows] = (await db.execute(sql`
SELECT id FROM catalog_pages
WHERE parent_id IN (${sql.join(children, sql`, `)})
`)) as unknown as [Array<{ id: number }>, unknown];
pageIds.push(...grandRows.map((r) => Number(r.id)));
}
return pageIds;
}
/** Caption of the imported-furniture root page, when present. */
export async function getImportedRootCaption(): Promise<string | null> {
const [rows] = (await db.execute(sql`
SELECT caption FROM catalog_pages
WHERE caption_save = 'imported_furni'
AND parent_id IN (-1, 0)
LIMIT 1
`)) as unknown as [Array<{ caption: string }>, unknown];
return rows[0]?.caption ?? null;
}