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
@@ -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(),
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user