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
@@ -7,6 +7,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Copy,
|
||||
Dumbbell,
|
||||
FolderTree,
|
||||
Image as ImageIcon,
|
||||
type LucideIcon,
|
||||
Puzzle,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
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 {
|
||||
Popover,
|
||||
@@ -113,6 +115,7 @@ export function StudioLayoutClient({
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname() ?? "";
|
||||
const [organizeOpen, setOrganizeOpen] = useState(false);
|
||||
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)]">
|
||||
<nav
|
||||
@@ -144,11 +147,21 @@ export function StudioLayoutClient({
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{canEditCatalog && (
|
||||
<CatalogManagerDialog
|
||||
catalogType="normal"
|
||||
canEdit
|
||||
triggerLabel="Catalog manager"
|
||||
/>
|
||||
<>
|
||||
<CatalogManagerDialog
|
||||
catalogType="normal"
|
||||
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
|
||||
label="Import & sync"
|
||||
@@ -163,6 +176,12 @@ export function StudioLayoutClient({
|
||||
</div>
|
||||
</nav>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto">{children}</div>
|
||||
{canEditCatalog && (
|
||||
<OrganizeImportsDialog
|
||||
open={organizeOpen}
|
||||
onOpenChange={setOrganizeOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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