diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index cbce816b..80b25037 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -347,3 +347,181 @@ export async function createAutoCategory(input: { 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 } }; + }); +} diff --git a/src/app/admin/studio/studio-layout-client.tsx b/src/app/admin/studio/studio-layout-client.tsx index 9a65d237..ef6183cc 100644 --- a/src/app/admin/studio/studio-layout-client.tsx +++ b/src/app/admin/studio/studio-layout-client.tsx @@ -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 (
{children}
+ {canEditCatalog && ( + + )}
); } diff --git a/src/app/api/admin/import/organize/route.ts b/src/app/api/admin/import/organize/route.ts new file mode 100644 index 00000000..eb80ae04 --- /dev/null +++ b/src/app/api/admin/import/organize/route.ts @@ -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(); + + // 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[], 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[], 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(), + }); + }, +); diff --git a/src/components/admin/studio/organize-imports-dialog.tsx b/src/components/admin/studio/organize-imports-dialog.tsx new file mode 100644 index 00000000..19c72cee --- /dev/null +++ b/src/components/admin/studio/organize-imports-dialog.tsx @@ -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([]); + const [rootPages, setRootPages] = useState([]); + const [importRootPageId, setImportRootPageId] = useState(null); + const [importRootCaption, setImportRootCaption] = useState( + null, + ); + + const [parentId, setParentId] = useState(-1); + const [approved, setApproved] = useState>({}); + const [overrides, setOverrides] = useState< + Record + >({}); + 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 ( + + + + + + {t("title")} + {totalSelected > 0 && !loading && ( + + {t("itemsBadge", { count: totalSelected })} + + )} + + + +
+ {/* Price (new offers only) */} + {!loading && items.length > 0 && ( +
+ + {t("price")} + + + + {!freeMode && ( + <> + + + + {t("pointsType")} + + + + )} +

+ {t("newPriceHint")} +

+
+ )} + + {/* Parent */} + {!loading && items.length > 0 && ( +
+ {t("parent")} + +
+ )} + + {/* Loading / empty / groups */} + {loading ? ( +
+ + {t("loading")} +
+ ) : items.length === 0 ? ( +
+ {t("empty")} + +
+ ) : ( + <> +
+

{t("desc")}

+ +
+ + {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 ( + + ); + })} + + )} +
+ +
+ + +
+
+
+ ); +} + +function FurniThumb({ + item, + size = 24, +}: { + item: { itemName: string }; + size?: number; +}) { + const primary = getLocalIconUrl(item.itemName); + return ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {item.itemName} { + const img = e.target as HTMLImageElement; + img.style.opacity = "0"; + }} + loading="lazy" + /> +
+ ); +} diff --git a/src/features/catalog/domain/organize.test.ts b/src/features/catalog/domain/organize.test.ts index 6a234e37..18acee14 100644 --- a/src/features/catalog/domain/organize.test.ts +++ b/src/features/catalog/domain/organize.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import type { CatalogItemData } from "@/app/admin/catalog/[id]/catalog-items-table/types"; import { buildSortOrders, + groupSuggestedCategories, type SortStrategy, type SugFurni, + type SugFurniEntry, sortCatalogItems, suggestCatalogIcon, suggestCatalogLayout, @@ -227,3 +229,64 @@ describe("suggestCatalogIcon", () => { 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"); + }); +}); diff --git a/src/features/catalog/domain/organize.ts b/src/features/catalog/domain/organize.ts index adaec86c..add36720 100644 --- a/src/features/catalog/domain/organize.ts +++ b/src/features/catalog/domain/organize.ts @@ -128,7 +128,7 @@ function layoutFromSignals(params: { /** Layout suggestion using base-item fields directly (for the auto-category wizard). */ export function suggestCatalogLayoutFromBaseItems( - items: Array<{ type?: string; interactionType?: string }>, + items: Array<{ type?: string | null; interactionType?: string | null }>, ): LayoutSuggestion | null { if (items.length === 0) return null; const interactions = new Set( @@ -264,6 +264,24 @@ function pluralizeLabel(token: string): string { 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. * 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(); for (const f of furni) { - const inter = f.interactionType?.toLowerCase(); - let label: string | undefined; - if (inter && inter !== "default") { - label = INTERACTION_LABELS[inter]; - } - if (!label) { - const token = classToken(f); - if (token) label = pluralizeLabel(token); - } + const label = unitLabel(f); if (label) labelCounts.set(label, (labelCounts.get(label) ?? 0) + 1); } @@ -328,3 +338,48 @@ export function suggestCatalogIcon(furni: SugFurni[]): number { if (!name) return 0; return CATEGORY_ICON_MAP[name] ?? 1; } + +// ── Import organization grouping ───────────────────────────── + +export type SugFurniEntry = SugFurni & { id: number }; + +export interface SuggestedFurniGroup { + 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( + entries: T[], +): SuggestedFurniGroup[] { + const buckets = new Map(); + for (const entry of entries) { + const key = unitLabel(entry) ?? REMAINDER_LABEL; + buckets.set(key, [...(buckets.get(key) ?? []), entry]); + } + + const groups: SuggestedFurniGroup[] = []; + 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), + ); +} diff --git a/src/features/catalog/server/import-pages.ts b/src/features/catalog/server/import-pages.ts new file mode 100644 index 00000000..b84baca0 --- /dev/null +++ b/src/features/catalog/server/import-pages.ts @@ -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 { + 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 { + 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; +} diff --git a/src/messages/en.json b/src/messages/en.json index c5cc06a6..8c0d4a3f 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2198,6 +2198,36 @@ "cancel": "Cancel", "create": "Create", "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": { diff --git a/src/messages/nl.json b/src/messages/nl.json index 4437863c..b2990c9e 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1966,6 +1966,36 @@ "cancel": "Annuleren", "create": "Aanmaken", "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": {