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
@@ -347,3 +347,181 @@ export async function createAutoCategory(input: {
|
|||||||
return { ok: true as const, data: { pageId } };
|
return { ok: true as const, data: { pageId } };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MAX_ORGANIZE_GROUPS = 50;
|
||||||
|
const MAX_ORGANIZE_ITEMS = 1000;
|
||||||
|
|
||||||
|
export interface OrganizeImportGroup {
|
||||||
|
caption: string;
|
||||||
|
pageLayout: string;
|
||||||
|
iconImage: number;
|
||||||
|
items: Array<{
|
||||||
|
itemId: number;
|
||||||
|
catalogItemId: number | null;
|
||||||
|
credits?: number;
|
||||||
|
points?: number;
|
||||||
|
pointsType?: number;
|
||||||
|
amount?: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn approved import suggestions into real catalog pages in one export
|
||||||
|
* snapshot. Offers that already exist inside the imported-furniture tree are
|
||||||
|
* moved to the freshly created page; brand-new furniture gets a new offer.
|
||||||
|
* Returns per-page move/add counts so the UI can summarize what happened.
|
||||||
|
*/
|
||||||
|
export async function organizeImportFurni(input: {
|
||||||
|
parentId: number;
|
||||||
|
groups: OrganizeImportGroup[];
|
||||||
|
}): Promise<
|
||||||
|
ActionResult<{
|
||||||
|
created: Array<{
|
||||||
|
pageId: number;
|
||||||
|
caption: string;
|
||||||
|
moved: number;
|
||||||
|
added: number;
|
||||||
|
}>;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||||
|
|
||||||
|
const groups = input.groups.filter((g) => (g.items?.length ?? 0) > 0);
|
||||||
|
if (groups.length === 0) {
|
||||||
|
return { ok: false as const, error: "No groups selected" };
|
||||||
|
}
|
||||||
|
if (groups.length > MAX_ORGANIZE_GROUPS) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: `Max ${MAX_ORGANIZE_GROUPS} groups per run`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const totalItems = groups.reduce((sum, g) => sum + g.items.length, 0);
|
||||||
|
if (totalItems > MAX_ORGANIZE_ITEMS) {
|
||||||
|
return {
|
||||||
|
ok: false as const,
|
||||||
|
error: `Max ${MAX_ORGANIZE_ITEMS} items per run`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return await withCatalogExport(async () => {
|
||||||
|
const { getImportedCategoryPageIds } = await import(
|
||||||
|
"@/features/catalog/server/import-pages"
|
||||||
|
);
|
||||||
|
const importPageIds = new Set(await getImportedCategoryPageIds());
|
||||||
|
|
||||||
|
const created: Array<{
|
||||||
|
pageId: number;
|
||||||
|
caption: string;
|
||||||
|
moved: number;
|
||||||
|
added: number;
|
||||||
|
}> = [];
|
||||||
|
let totalMoved = 0;
|
||||||
|
let totalAdded = 0;
|
||||||
|
|
||||||
|
for (const group of groups) {
|
||||||
|
const pageId = Number(
|
||||||
|
await createPageCommand("normal", {
|
||||||
|
caption: group.caption,
|
||||||
|
parentId: input.parentId,
|
||||||
|
pageLayout: group.pageLayout || "default_3x3",
|
||||||
|
captionSave: group.caption.slice(0, 25),
|
||||||
|
iconImage: group.iconImage ?? 0,
|
||||||
|
iconColor: 0,
|
||||||
|
minRank: 1,
|
||||||
|
orderNum: 0,
|
||||||
|
visible: "1",
|
||||||
|
enabled: "1",
|
||||||
|
clubOnly: "0",
|
||||||
|
vipOnly: "0",
|
||||||
|
pageHeadline: "",
|
||||||
|
pageTeaser: "",
|
||||||
|
includes: "",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const baseIds = [...new Set(group.items.map((r) => r.itemId))];
|
||||||
|
const bases = await db
|
||||||
|
.select({
|
||||||
|
id: ItemsBase.id,
|
||||||
|
publicName: ItemsBase.publicName,
|
||||||
|
itemName: ItemsBase.itemName,
|
||||||
|
})
|
||||||
|
.from(ItemsBase)
|
||||||
|
.where(inArray(ItemsBase.id, baseIds));
|
||||||
|
const baseMap = new Map(bases.map((b) => [b.id, b]));
|
||||||
|
|
||||||
|
let nextOrder = 1;
|
||||||
|
let moved = 0;
|
||||||
|
let added = 0;
|
||||||
|
for (const row of group.items) {
|
||||||
|
const base = baseMap.get(row.itemId);
|
||||||
|
if (!base) continue;
|
||||||
|
const catalogName =
|
||||||
|
base.publicName || base.itemName || String(row.itemId);
|
||||||
|
|
||||||
|
if (row.catalogItemId) {
|
||||||
|
// Verify the offer currently sits inside the import tree before
|
||||||
|
// moving it, so we never hijack an unrelated catalog offer.
|
||||||
|
const [host] = (await db.execute(sql`
|
||||||
|
SELECT page_id FROM catalog_items WHERE id = ${row.catalogItemId}
|
||||||
|
`)) as unknown as [Array<{ page_id: number }>, unknown];
|
||||||
|
const hostPageId = Number(host[0]?.page_id ?? -1);
|
||||||
|
if (!importPageIds.has(hostPageId)) {
|
||||||
|
console.error(
|
||||||
|
`[organizeImportFurni] skipping offer #${row.catalogItemId} on non-import page #${hostPageId}`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await db.execute(sql`
|
||||||
|
UPDATE catalog_items
|
||||||
|
SET page_id = ${pageId}, order_number = ${nextOrder}, catalog_name = ${catalogName}
|
||||||
|
WHERE id = ${row.catalogItemId}
|
||||||
|
`);
|
||||||
|
moved++;
|
||||||
|
} else {
|
||||||
|
await insertCatalogItemRow({
|
||||||
|
pageId,
|
||||||
|
itemIds: String(row.itemId),
|
||||||
|
catalogName,
|
||||||
|
costCredits: row.credits ?? 0,
|
||||||
|
costPoints: row.points ?? 0,
|
||||||
|
pointsType: row.pointsType ?? 0,
|
||||||
|
amount: row.amount ?? 1,
|
||||||
|
limitedSells: 0,
|
||||||
|
limitedStack: 0,
|
||||||
|
orderNumber: nextOrder,
|
||||||
|
offerId: -1,
|
||||||
|
songId: 0,
|
||||||
|
haveOffer: "1",
|
||||||
|
clubOnly: "0",
|
||||||
|
extradata: "",
|
||||||
|
});
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
nextOrder++;
|
||||||
|
}
|
||||||
|
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_page_create",
|
||||||
|
description: `Organized import into page #${pageId} "${group.caption}" (${moved} moved, ${added} added)`,
|
||||||
|
targetType: "catalog_page",
|
||||||
|
targetId: pageId,
|
||||||
|
});
|
||||||
|
created.push({ pageId, caption: group.caption, moved, added });
|
||||||
|
totalMoved += moved;
|
||||||
|
totalAdded += added;
|
||||||
|
}
|
||||||
|
|
||||||
|
await sendCatalogUpdate();
|
||||||
|
await logStaffActivity({
|
||||||
|
staffId: staff.id,
|
||||||
|
action: "catalog_import_organize",
|
||||||
|
description: `Organized ${created.length} import page(s): ${totalMoved} offer(s) moved, ${totalAdded} offer(s) added`,
|
||||||
|
targetType: "catalog",
|
||||||
|
});
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
|
return { ok: true as const, data: { created } };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -0,0 +1,657 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
FolderTree,
|
||||||
|
Home,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Wand2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { organizeImportFurni } from "@/actions/catalog";
|
||||||
|
import { IconPicker } from "@/components/admin/catalog/icon-picker";
|
||||||
|
import { CatalogIcon, LAYOUT_COLORS } from "@/components/admin/catalog-tree";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { groupSuggestedCategories } from "@/features/catalog/domain/organize";
|
||||||
|
import { CATALOG_LAYOUTS, type CatalogLayout } from "@/lib/catalog-layouts";
|
||||||
|
import { getLocalIconUrl } from "@/lib/furni/classname";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface OrganizeItem {
|
||||||
|
id: number;
|
||||||
|
itemId: number;
|
||||||
|
itemName: string;
|
||||||
|
publicName: string;
|
||||||
|
type: string;
|
||||||
|
spriteId: number;
|
||||||
|
interactionType: string;
|
||||||
|
catalogItemId: number | null;
|
||||||
|
sourcePageId: number | null;
|
||||||
|
sourcePageCaption: string | null;
|
||||||
|
alreadyPlaced: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RootPage {
|
||||||
|
id: number;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrganizeGroupOverride {
|
||||||
|
caption?: string;
|
||||||
|
icon?: number;
|
||||||
|
layout?: CatalogLayout;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OrganizeImportsDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrganizeImportsDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: OrganizeImportsDialogProps) {
|
||||||
|
const t = useTranslations("pages.admin.catalog.workspace.organizeImports");
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [items, setItems] = useState<OrganizeItem[]>([]);
|
||||||
|
const [rootPages, setRootPages] = useState<RootPage[]>([]);
|
||||||
|
const [importRootPageId, setImportRootPageId] = useState<number | null>(null);
|
||||||
|
const [importRootCaption, setImportRootCaption] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const [parentId, setParentId] = useState(-1);
|
||||||
|
const [approved, setApproved] = useState<Record<string, boolean>>({});
|
||||||
|
const [overrides, setOverrides] = useState<
|
||||||
|
Record<string, OrganizeGroupOverride>
|
||||||
|
>({});
|
||||||
|
const [isCreating, setIsCreating] = useState(false);
|
||||||
|
|
||||||
|
const [freeMode, setFreeMode] = useState(true);
|
||||||
|
const [credits, setCredits] = useState(0);
|
||||||
|
const [points, setPoints] = useState(0);
|
||||||
|
const [pointsType, setPointsType] = useState(0);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [data, tree] = await Promise.all([
|
||||||
|
fetch("/api/admin/import/organize").then((r) => r.json()),
|
||||||
|
fetch("/api/admin/catalog/tree?parentId=-1").then((r) => r.json()),
|
||||||
|
]);
|
||||||
|
setItems(Array.isArray(data?.items) ? data.items : []);
|
||||||
|
setImportRootPageId(data?.importRootPageId ?? null);
|
||||||
|
setImportRootCaption(data?.importRootCaption ?? null);
|
||||||
|
const pages = Array.isArray(tree?.pages) ? tree.pages : [];
|
||||||
|
setRootPages(
|
||||||
|
pages.map((p: { id: number; caption: string }) => ({
|
||||||
|
id: Number(p.id),
|
||||||
|
caption: String(p.caption ?? p.id),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OrganizeImports] load failed:", error);
|
||||||
|
toast.error("Failed to load imports");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setItems([]);
|
||||||
|
setApproved({});
|
||||||
|
setOverrides({});
|
||||||
|
setParentId(-1);
|
||||||
|
setFreeMode(true);
|
||||||
|
setCredits(0);
|
||||||
|
setPoints(0);
|
||||||
|
setPointsType(0);
|
||||||
|
void load();
|
||||||
|
}, [open, load]);
|
||||||
|
|
||||||
|
const groups = useMemo(() => groupSuggestedCategories(items), [items]);
|
||||||
|
|
||||||
|
const allApproved =
|
||||||
|
groups.length > 0 && groups.every((g) => approved[g.name]);
|
||||||
|
const totalSelected = useMemo(
|
||||||
|
() =>
|
||||||
|
groups.reduce(
|
||||||
|
(sum, g) => sum + (approved[g.name] ? g.items.length : 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
[groups, approved],
|
||||||
|
);
|
||||||
|
|
||||||
|
const parentOptions = useMemo(() => {
|
||||||
|
const options: RootPage[] = [];
|
||||||
|
for (const p of rootPages) {
|
||||||
|
if (importRootPageId != null && p.id === importRootPageId) continue;
|
||||||
|
options.push(p);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}, [rootPages, importRootPageId]);
|
||||||
|
|
||||||
|
const toggleAll = () => {
|
||||||
|
const next = !allApproved;
|
||||||
|
setApproved(Object.fromEntries(groups.map((g) => [g.name, next])));
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateGroup = (name: string, patch: OrganizeGroupOverride) => {
|
||||||
|
setOverrides((prev) => ({ ...prev, [name]: { ...prev[name], ...patch } }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (isCreating) return;
|
||||||
|
const payload = groups
|
||||||
|
.filter((g) => approved[g.name])
|
||||||
|
.map((g) => {
|
||||||
|
const o = overrides[g.name] ?? {};
|
||||||
|
return {
|
||||||
|
caption: (o.caption ?? g.name).trim(),
|
||||||
|
pageLayout: o.layout ?? g.layout?.layout ?? "default_3x3",
|
||||||
|
iconImage: o.icon ?? g.icon,
|
||||||
|
items: g.items.map((item) => ({
|
||||||
|
itemId: item.id,
|
||||||
|
catalogItemId: item.catalogItemId,
|
||||||
|
credits: freeMode ? 0 : credits,
|
||||||
|
points: freeMode ? 0 : points,
|
||||||
|
pointsType: freeMode ? 0 : pointsType,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
if (payload.length === 0 || payload.some((g) => !g.caption)) {
|
||||||
|
toast.error("Every approved page needs a name");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsCreating(true);
|
||||||
|
try {
|
||||||
|
const result = await organizeImportFurni({ parentId, groups: payload });
|
||||||
|
if (!result.ok) throw new Error(result.error ?? "Failed");
|
||||||
|
const created = result.data?.created ?? [];
|
||||||
|
toast.success(
|
||||||
|
`Organized ${created.length} import page${created.length === 1 ? "" : "s"}`,
|
||||||
|
);
|
||||||
|
onOpenChange(false);
|
||||||
|
router.refresh();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[OrganizeImports] create failed:", error);
|
||||||
|
toast.error("Failed to organize imports");
|
||||||
|
} finally {
|
||||||
|
setIsCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex h-[90vh] max-h-[90vh] w-[800px] max-w-[95vw] flex-col overflow-hidden p-0">
|
||||||
|
<DialogHeader className="flex-none border-b px-4 py-3">
|
||||||
|
<DialogTitle className="flex items-center gap-2 text-sm font-semibold">
|
||||||
|
<FolderTree className="h-4 w-4" />
|
||||||
|
{t("title")}
|
||||||
|
{totalSelected > 0 && !loading && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{t("itemsBadge", { count: totalSelected })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4">
|
||||||
|
{/* Price (new offers only) */}
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-md border bg-[var(--admin-surface)] p-3 text-xs">
|
||||||
|
<span className="text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("price")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={freeMode ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
onClick={() => setFreeMode(true)}
|
||||||
|
>
|
||||||
|
{t("free")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={!freeMode ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
className="h-6 px-2 text-[10px]"
|
||||||
|
onClick={() => setFreeMode(false)}
|
||||||
|
>
|
||||||
|
{t("setPrice")}
|
||||||
|
</Button>
|
||||||
|
{!freeMode && (
|
||||||
|
<>
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
{t("credits")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
|
||||||
|
value={credits}
|
||||||
|
onChange={(e) => setCredits(Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
{t("points")}
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-16 rounded border bg-[var(--admin-canvas)] px-1.5 py-1 text-xs"
|
||||||
|
value={points}
|
||||||
|
onChange={(e) => setPoints(Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<span className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||||
|
{t("pointsType")}
|
||||||
|
<Select
|
||||||
|
value={String(pointsType)}
|
||||||
|
onValueChange={(v) => setPointsType(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-6 w-24 text-[10px]">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0" className="text-xs">
|
||||||
|
Duckets
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="5" className="text-xs">
|
||||||
|
Diamonds
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<p className="w-full text-[10px] text-muted-foreground">
|
||||||
|
{t("newPriceHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Parent */}
|
||||||
|
{!loading && items.length > 0 && (
|
||||||
|
<div className="flex flex-col gap-1 rounded-md border bg-[var(--admin-surface)] p-3 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
{t("parent")}
|
||||||
|
<Select
|
||||||
|
value={String(parentId)}
|
||||||
|
onValueChange={(v) => setParentId(Number(v))}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="-1" className="text-xs">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Home className="h-3 w-3 shrink-0" />
|
||||||
|
{t("root")}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
{importRootPageId != null && (
|
||||||
|
<SelectItem
|
||||||
|
value={String(importRootPageId)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Folder className="h-3 w-3 shrink-0" />
|
||||||
|
{importRootCaption ?? t("importedRoot")}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
)}
|
||||||
|
{parentOptions.map((p) => (
|
||||||
|
<SelectItem
|
||||||
|
key={p.id}
|
||||||
|
value={String(p.id)}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<Folder className="h-3 w-3 shrink-0" />
|
||||||
|
{p.caption}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading / empty / groups */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
{t("loading")}
|
||||||
|
</div>
|
||||||
|
) : items.length === 0 ? (
|
||||||
|
<div className="flex flex-1 flex-col items-center justify-center gap-3 text-sm text-muted-foreground">
|
||||||
|
{t("empty")}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => void load()}
|
||||||
|
>
|
||||||
|
<RefreshCw className="mr-1 h-3 w-3" />
|
||||||
|
{t("refresh")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("desc")}</p>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-[10px]"
|
||||||
|
onClick={toggleAll}
|
||||||
|
>
|
||||||
|
{allApproved ? t("deselectAll") : t("selectAll")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{groups.map((group) => {
|
||||||
|
const o = overrides[group.name] ?? {};
|
||||||
|
const caption = o.caption ?? group.name;
|
||||||
|
const icon = o.icon ?? group.icon;
|
||||||
|
const layout =
|
||||||
|
o.layout ?? group.layout?.layout ?? "default_3x3";
|
||||||
|
const isApproved = approved[group.name] !== false;
|
||||||
|
const moved = group.items.filter((i) => i.alreadyPlaced).length;
|
||||||
|
const added = group.items.length - moved;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={group.name}
|
||||||
|
className={cn(
|
||||||
|
"block rounded-md border bg-[var(--admin-surface)] p-3 transition-colors",
|
||||||
|
isApproved
|
||||||
|
? "border-[var(--admin-accent)]/40"
|
||||||
|
: "opacity-60",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isApproved}
|
||||||
|
onChange={(e) =>
|
||||||
|
setApproved((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[group.name]: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<CatalogIcon iconImage={group.icon} size={20} />
|
||||||
|
<p className="min-w-0 flex-1 truncate text-sm font-semibold">
|
||||||
|
{group.name}
|
||||||
|
</p>
|
||||||
|
<Badge variant="secondary" className="h-4 text-[9px]">
|
||||||
|
{group.items.length}
|
||||||
|
</Badge>
|
||||||
|
{moved > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-4 gap-1 text-[9px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("movedLabel", { count: moved })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{added > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="h-4 gap-1 text-[9px] text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t("newLabel", { count: added })}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 space-y-2 border-t pt-2 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-12 shrink-0 text-[10px]">
|
||||||
|
{t("name")}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
className="h-7 text-xs"
|
||||||
|
value={caption}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateGroup(group.name, {
|
||||||
|
caption: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{caption !== group.name && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 px-2 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateGroup(group.name, { caption: group.name });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||||
|
{t("resetSuggestion")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-12 shrink-0 text-[10px]">
|
||||||
|
{t("icon")}
|
||||||
|
</span>
|
||||||
|
<IconPicker
|
||||||
|
value={icon}
|
||||||
|
onChange={(id) =>
|
||||||
|
updateGroup(group.name, { icon: id })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1.5 px-2"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<CatalogIcon iconImage={icon} size={16} />
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">
|
||||||
|
#{icon}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</IconPicker>
|
||||||
|
{icon !== group.icon && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateGroup(group.name, { icon: group.icon });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||||
|
{group.icon}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="w-12 shrink-0 text-[10px]">
|
||||||
|
{t("layout")}
|
||||||
|
</span>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{CATALOG_LAYOUTS.map((l) => {
|
||||||
|
const color =
|
||||||
|
LAYOUT_COLORS[l] ||
|
||||||
|
"bg-muted text-muted-foreground";
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={l}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateGroup(group.name, { layout: l });
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"rounded border px-1.5 py-0.5 font-mono text-[10px] transition-colors",
|
||||||
|
layout === l
|
||||||
|
? cn(
|
||||||
|
color,
|
||||||
|
"border-current ring-1 ring-current/20",
|
||||||
|
)
|
||||||
|
: "border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text-muted)] hover:border-current",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{l.replace("default_3x3", "3x3")}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{group.layout &&
|
||||||
|
layout !== group.layout.layout &&
|
||||||
|
group.layout.layout !== "default_3x3" && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-6 w-fit border border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)] px-2 text-[10px] text-[var(--admin-warning)]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
updateGroup(group.name, {
|
||||||
|
layout: group.layout?.layout,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||||
|
{t("suggestedLayout", {
|
||||||
|
layout: group.layout.layout,
|
||||||
|
})}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-1.5 border-t pt-2">
|
||||||
|
{group.items.slice(0, 12).map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="group flex flex-col items-center gap-0.5"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="relative"
|
||||||
|
title={item.publicName || item.itemName}
|
||||||
|
>
|
||||||
|
<FurniThumb item={item} />
|
||||||
|
</div>
|
||||||
|
{item.alreadyPlaced ? (
|
||||||
|
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
|
||||||
|
{item.sourcePageCaption
|
||||||
|
? item.sourcePageCaption.replace(/^Imp /, "")
|
||||||
|
: "moved"}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="max-w-14 truncate text-[8px] text-muted-foreground">
|
||||||
|
{t("isNew")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{group.items.length > 12 && (
|
||||||
|
<p className="self-center text-[10px] text-muted-foreground">
|
||||||
|
+{group.items.length - 12}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-none justify-end gap-2 border-t px-4 py-2.5">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{t("cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
className="gap-1"
|
||||||
|
disabled={loading || isCreating || totalSelected === 0}
|
||||||
|
onClick={() => void handleCreate()}
|
||||||
|
>
|
||||||
|
{isCreating ? (
|
||||||
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{t("create", { count: totalSelected })}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FurniThumb({
|
||||||
|
item,
|
||||||
|
size = 24,
|
||||||
|
}: {
|
||||||
|
item: { itemName: string };
|
||||||
|
size?: number;
|
||||||
|
}) {
|
||||||
|
const primary = getLocalIconUrl(item.itemName);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center rounded bg-muted/50"
|
||||||
|
style={{ width: size, height: size }}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={primary}
|
||||||
|
alt={item.itemName}
|
||||||
|
className="max-h-full max-w-full object-contain"
|
||||||
|
style={{ imageRendering: "pixelated" }}
|
||||||
|
onError={(e) => {
|
||||||
|
const img = e.target as HTMLImageElement;
|
||||||
|
img.style.opacity = "0";
|
||||||
|
}}
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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