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
File diff suppressed because it is too large.
Load diff
@@ -7,6 +7,7 @@ import {
|
|||||||
ClipboardCheck,
|
ClipboardCheck,
|
||||||
Copy,
|
Copy,
|
||||||
Dumbbell,
|
Dumbbell,
|
||||||
|
FolderTree,
|
||||||
Image as ImageIcon,
|
Image as ImageIcon,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
Puzzle,
|
Puzzle,
|
||||||
@@ -18,6 +19,7 @@ import {
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { CatalogManagerDialog } from "@/app/admin/catalog/catalog-manager-dialog-wrapper";
|
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 Link from "@/components/link";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
@@ -113,6 +115,7 @@ export function StudioLayoutClient({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
const pathname = usePathname() ?? "";
|
const pathname = usePathname() ?? "";
|
||||||
|
const [organizeOpen, setOrganizeOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
<div className="flex h-[calc(100dvh-7rem)] min-h-[600px] min-w-0 flex-col overflow-hidden rounded-xl border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)]">
|
||||||
<nav
|
<nav
|
||||||
@@ -144,11 +147,21 @@ export function StudioLayoutClient({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{canEditCatalog && (
|
{canEditCatalog && (
|
||||||
<CatalogManagerDialog
|
<>
|
||||||
catalogType="normal"
|
<CatalogManagerDialog
|
||||||
canEdit
|
catalogType="normal"
|
||||||
triggerLabel="Catalog manager"
|
canEdit
|
||||||
/>
|
triggerLabel="Catalog manager"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOrganizeOpen(true)}
|
||||||
|
className="flex min-h-10 items-center gap-2 rounded-lg px-3 text-sm font-medium text-[var(--admin-text-muted)] hover:bg-[var(--admin-accent)]/10"
|
||||||
|
>
|
||||||
|
<FolderTree size={16} />
|
||||||
|
Organize imports
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<StudioMenu
|
<StudioMenu
|
||||||
label="Import & sync"
|
label="Import & sync"
|
||||||
@@ -163,6 +176,12 @@ export function StudioLayoutClient({
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
|
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
|
||||||
|
{canEditCatalog && (
|
||||||
|
<OrganizeImportsDialog
|
||||||
|
open={organizeOpen}
|
||||||
|
onOpenChange={setOrganizeOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||||
import {
|
import {
|
||||||
buildSortOrders,
|
buildSortOrders,
|
||||||
|
groupSuggestedCategories,
|
||||||
type SortStrategy,
|
type SortStrategy,
|
||||||
type SugFurni,
|
type SugFurni,
|
||||||
|
type SugFurniEntry,
|
||||||
sortCatalogItems,
|
sortCatalogItems,
|
||||||
suggestCatalogIcon,
|
suggestCatalogIcon,
|
||||||
suggestCatalogLayout,
|
suggestCatalogLayout,
|
||||||
@@ -227,3 +229,64 @@ describe("suggestCatalogIcon", () => {
|
|||||||
expect(a).toBe(b);
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -128,7 +128,7 @@ function layoutFromSignals(params: {
|
|||||||
|
|
||||||
/** Layout suggestion using base-item fields directly (for the auto-category wizard). */
|
/** Layout suggestion using base-item fields directly (for the auto-category wizard). */
|
||||||
export function suggestCatalogLayoutFromBaseItems(
|
export function suggestCatalogLayoutFromBaseItems(
|
||||||
items: Array<{ type?: string; interactionType?: string }>,
|
items: Array<{ type?: string | null; interactionType?: string | null }>,
|
||||||
): LayoutSuggestion | null {
|
): LayoutSuggestion | null {
|
||||||
if (items.length === 0) return null;
|
if (items.length === 0) return null;
|
||||||
const interactions = new Set(
|
const interactions = new Set(
|
||||||
@@ -264,6 +264,24 @@ function pluralizeLabel(token: string): string {
|
|||||||
return `${cap}s`;
|
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.
|
* Best-effort English category label derived from a batch of base items.
|
||||||
* Never blocks — returns null when it can't determine anything useful.
|
* 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>();
|
const labelCounts = new Map<string, number>();
|
||||||
for (const f of furni) {
|
for (const f of furni) {
|
||||||
const inter = f.interactionType?.toLowerCase();
|
const label = unitLabel(f);
|
||||||
let label: string | undefined;
|
|
||||||
if (inter && inter !== "default") {
|
|
||||||
label = INTERACTION_LABELS[inter];
|
|
||||||
}
|
|
||||||
if (!label) {
|
|
||||||
const token = classToken(f);
|
|
||||||
if (token) label = pluralizeLabel(token);
|
|
||||||
}
|
|
||||||
if (label) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
if (label) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -328,3 +338,48 @@ export function suggestCatalogIcon(furni: SugFurni[]): number {
|
|||||||
if (!name) return 0;
|
if (!name) return 0;
|
||||||
return CATEGORY_ICON_MAP[name] ?? 1;
|
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;
|
||||||
|
}
|
||||||
@@ -2198,6 +2198,36 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
"createWithItems": "Create ({count} items)"
|
"createWithItems": "Create ({count} items)"
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"title": "Organize imports",
|
||||||
|
"itemsBadge": "{count} items",
|
||||||
|
"loading": "Loading imported furniture…",
|
||||||
|
"empty": "No imported furniture found. Import furniture first.",
|
||||||
|
"refresh": "Refresh",
|
||||||
|
"desc": "Imported furniture is grouped into suggested pages. Review each group, then create the pages you approve.",
|
||||||
|
"selectAll": "Select all",
|
||||||
|
"deselectAll": "Deselect all",
|
||||||
|
"name": "Name",
|
||||||
|
"icon": "Icon",
|
||||||
|
"layout": "Layout",
|
||||||
|
"resetSuggestion": "Suggested",
|
||||||
|
"suggestedLayout": "Suggested: {layout}",
|
||||||
|
"movedLabel": "{count} moved",
|
||||||
|
"newLabel": "{count} new",
|
||||||
|
"isNew": "new",
|
||||||
|
"price": "Price",
|
||||||
|
"free": "Free",
|
||||||
|
"setPrice": "Set a price",
|
||||||
|
"credits": "Credits",
|
||||||
|
"points": "Points",
|
||||||
|
"pointsType": "Type",
|
||||||
|
"newPriceHint": "Applies to new offers only; moved offers keep their current price.",
|
||||||
|
"parent": "Place pages under",
|
||||||
|
"root": "Catalog root",
|
||||||
|
"importedRoot": "Imported Furniture",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"create": "Create {count, plural, one {1 page} other {# pages}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
|
|||||||
@@ -1966,6 +1966,36 @@
|
|||||||
"cancel": "Annuleren",
|
"cancel": "Annuleren",
|
||||||
"create": "Aanmaken",
|
"create": "Aanmaken",
|
||||||
"createWithItems": "Aanmaken ({count} items)"
|
"createWithItems": "Aanmaken ({count} items)"
|
||||||
|
},
|
||||||
|
"organizeImports": {
|
||||||
|
"title": "Imports organiseren",
|
||||||
|
"itemsBadge": "{count} items",
|
||||||
|
"loading": "Geïmporteerde meubels laden…",
|
||||||
|
"empty": "Geen geïmporteerde meubels gevonden. Importeer eerst meubels.",
|
||||||
|
"refresh": "Vernieuwen",
|
||||||
|
"desc": "Geïmporteerde meubels worden gegroepeerd in voorgestelde pagina's. Bekijk elke groep en maak alleen de pagina's aan die je goedkeurt.",
|
||||||
|
"selectAll": "Alles selecteren",
|
||||||
|
"deselectAll": "Alles deselecteren",
|
||||||
|
"name": "Naam",
|
||||||
|
"icon": "Icoon",
|
||||||
|
"layout": "Indeling",
|
||||||
|
"resetSuggestion": "Voorgesteld",
|
||||||
|
"suggestedLayout": "Voorgesteld: {layout}",
|
||||||
|
"movedLabel": "{count} verplaatst",
|
||||||
|
"newLabel": "{count} nieuw",
|
||||||
|
"isNew": "nieuw",
|
||||||
|
"price": "Prijs",
|
||||||
|
"free": "Gratis",
|
||||||
|
"setPrice": "Stel een prijs in",
|
||||||
|
"credits": "Credits",
|
||||||
|
"points": "Punten",
|
||||||
|
"pointsType": "Type",
|
||||||
|
"newPriceHint": "Geldt alleen voor nieuwe aanbiedingen; verplaatste aanbiedingen behouden hun huidige prijs.",
|
||||||
|
"parent": "Pagina's plaatsen onder",
|
||||||
|
"root": "Catalogus-hoofdmap",
|
||||||
|
"importedRoot": "Imported Furniture",
|
||||||
|
"cancel": "Annuleren",
|
||||||
|
"create": "{count, plural, one {1 pagina aanmaken} other {# pagina's aanmaken}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bulk": {
|
"bulk": {
|
||||||
|
|||||||
Reference in new issue
Block a user