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

+136
View File
@@ -0,0 +1,136 @@
import { sql } from "drizzle-orm";
import {
getImportedCategoryPageIds,
getImportedRootCaption,
} from "@/features/catalog/server/import-pages";
import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { db } from "@/lib/db";
import { PERMS } from "@/lib/permissions";
interface OrganizeItem {
itemId: number;
itemName: string;
publicName: string;
type: string;
spriteId: number;
interactionType: string;
catalogItemId: number | null;
sourcePageId: number | null;
sourcePageCaption: string | null;
alreadyPlaced: boolean;
}
const columns = sql`
ib.id AS itemId,
ib.item_name AS itemName,
ib.public_name AS publicName,
ib.type AS type,
ib.sprite_id AS spriteId,
ib.interaction_type AS interactionType
`;
export const GET = withAdmin(
{ permission: PERMS.CATALOG_VIEW },
async (request) => {
const sp = request.nextUrl.searchParams;
const limit = Math.min(
Math.max(Number(sp.get("limit") || 500) || 500, 1),
1000,
);
const days = Math.min(Math.max(Number(sp.get("days") || 30) || 30, 1), 365);
const sinceIso = new Date(
Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString();
const since = sinceIso.slice(0, 19).replace("T", " ");
const pageIds = await getImportedCategoryPageIds();
const byItemId = new Map<number, OrganizeItem>();
// 1) Offers already living in the imported-furniture tree.
if (pageIds.length > 0) {
const [rows] = (await db.execute(sql`
SELECT ${columns},
ci.id AS catalogItemId,
ci.page_id AS sourcePageId,
p.caption AS sourcePageCaption
FROM catalog_items ci
INNER JOIN items_base ib ON ib.id = CAST(ci.item_ids AS UNSIGNED)
INNER JOIN catalog_pages p ON p.id = ci.page_id
WHERE ci.page_id IN (${sql.join(pageIds, sql`, `)})
ORDER BY ci.id
LIMIT ${limit}
`)) as unknown as [Record<string, unknown>[], unknown];
for (const r of rows) {
const itemId = Number(r.itemId);
if (!Number.isFinite(itemId)) continue;
byItemId.set(itemId, {
itemId,
itemName: String(r.itemName ?? ""),
publicName: String(r.publicName ?? ""),
type: String(r.type ?? "s"),
spriteId: Number(r.spriteId) || 0,
interactionType: String(r.interactionType ?? "default"),
catalogItemId: Number(r.catalogItemId),
sourcePageId: Number(r.sourcePageId) || null,
sourcePageCaption: r.sourcePageCaption
? String(r.sourcePageCaption)
: null,
alreadyPlaced: true,
});
}
}
// 2) Recently imported furniture that isn't in any catalog page yet.
const [auditRows] = (await db.execute(sql`
SELECT DISTINCT ${columns},
NULL AS catalogItemId,
NULL AS sourcePageId,
NULL AS sourcePageCaption
FROM admin_audit_log alog
INNER JOIN items_base ib ON ib.id = alog.target_id
WHERE alog.action = 'furni_import'
AND alog.target = 'ItemsBase'
AND alog.target_id IS NOT NULL
AND alog.created_at >= ${since}
AND NOT EXISTS (
SELECT 1 FROM catalog_items ci
WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ','))
)
ORDER BY itemId DESC
LIMIT ${limit}
`)) as unknown as [Record<string, unknown>[], unknown];
for (const r of auditRows) {
const itemId = Number(r.itemId);
if (!Number.isFinite(itemId)) continue;
if (byItemId.has(itemId)) continue;
byItemId.set(itemId, {
itemId,
itemName: String(r.itemName ?? ""),
publicName: String(r.publicName ?? ""),
type: String(r.type ?? "s"),
spriteId: Number(r.spriteId) || 0,
interactionType: String(r.interactionType ?? "default"),
catalogItemId: null,
sourcePageId: null,
sourcePageCaption: null,
alreadyPlaced: false,
});
}
const items = [...byItemId.values()].sort((a, b) => {
// Already-placed furniture first, newest item id first within each set.
return (
Number(b.alreadyPlaced) - Number(a.alreadyPlaced) || b.itemId - a.itemId
);
});
const importRootPageId = pageIds[0] ?? null;
return apiOk({
items,
total: items.length,
importRootPageId,
importRootCaption: await getImportedRootCaption(),
});
},
);