From caeb4eb0b3495730553814b31bb5ed15df65dbf1 Mon Sep 17 00:00:00 2001 From: openhands Date: Fri, 11 Sep 2026 20:44:26 +0200 Subject: [PATCH] feat(studio): allow moving imports into an existing page instead of creating ones Adds a second mode to the organize-imports dialog: instead of creating one new page per approved group (which could produce dozens of tiny pages), the user can pick an existing destination page and have every approved item moved into it. No catalog page is created in this mode. - organizeImportFurni: groups accept destinationPageId; when set, the existing page is reused, new offers append after its current highest order, moved offers keep their original name, and the real page caption is used for logging and results - OrganizeImportsDialog: mode toggle (create pages / move into page), searchable destination picker via /api/admin/catalog/tree?search=, name/icon/layout editors hidden in move mode, button shows a move count, and the success toast reports moved/added instead of pages - en + nl translations for the new mode, destination, and move keys --- src/actions/catalog.ts | 88 +++- .../admin/studio/organize-imports-dialog.tsx | 435 +++++++++++++----- src/messages/en.json | 10 + src/messages/nl.json | 10 + 4 files changed, 395 insertions(+), 148 deletions(-) diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index c1be0f6f..5e51d5f3 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -355,6 +355,8 @@ export interface OrganizeImportGroup { caption: string; pageLayout: string; iconImage: number; + /** When set, items are moved into this existing page instead of creating one. */ + destinationPageId?: number | null; items: Array<{ itemId: number; catalogItemId: number | null; @@ -420,25 +422,54 @@ export async function organizeImportFurni(input: { 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: "", - }), - ); + // Either create a fresh page or reuse an existing destination. + const destinationPageId = group.destinationPageId + ? Number(group.destinationPageId) + : null; + if (destinationPageId != null && !Number.isFinite(destinationPageId)) { + return { + ok: false as const, + error: "Invalid destination page id", + }; + } + const pageId = + destinationPageId ?? + 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: "", + }), + ); + let destinationCaption: string | null = null; + if (destinationPageId) { + const [captionRows] = (await db.execute(sql` + SELECT caption FROM catalog_pages WHERE id = ${destinationPageId} + `)) as unknown as [Array<{ caption: string }>, unknown]; + destinationCaption = captionRows[0]?.caption ?? null; + } + const caption = + destinationCaption ?? group.caption ?? `Category ${pageId}`; + + // Existing destinations append after their current offers; freshly + // created pages start at order 1. + const [maxOrder] = (await db.execute(sql` + SELECT COALESCE(MAX(order_number), 0) AS maxOrder + FROM catalog_items WHERE page_id = ${pageId} + `)) as unknown as [{ maxOrder: number }, unknown]; + let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1; const baseIds = [...new Set(group.items.map((r) => r.itemId))]; const bases = await db @@ -475,7 +506,6 @@ export async function organizeImportFurni(input: { } } - let nextOrder = 1; let moved = 0; let added = 0; @@ -529,11 +559,17 @@ export async function organizeImportFurni(input: { } if (moveIds.length > 0) { + // Preserve existing offer names when moving into an existing + // destination; freshly created pages get the base item's name. + const nameUpdate = + destinationPageId == null + ? sql`, catalog_name = CASE id ${sql.raw(moveCaseName.join(" "))} END` + : sql``; await db.execute(sql` UPDATE catalog_items SET page_id = ${pageId}, - order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END, - catalog_name = CASE id ${sql.raw(moveCaseName.join(" "))} END + order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END + ${nameUpdate} WHERE id IN (${sql.join( moveIds.map((id) => sql`${id}`), sql`, `, @@ -543,12 +579,14 @@ export async function organizeImportFurni(input: { await logStaffActivity({ staffId: staff.id, - action: "catalog_page_create", - description: `Organized import into page #${pageId} "${group.caption}" (${moved} moved, ${added} added)`, + action: destinationPageId + ? "catalog_items_bulk_update" + : "catalog_page_create", + description: `Organized import into page #${pageId} "${caption}" (${moved} moved, ${added} added)`, targetType: "catalog_page", targetId: pageId, }); - created.push({ pageId, caption: group.caption, moved, added }); + created.push({ pageId, caption, moved, added }); totalMoved += moved; totalAdded += added; } diff --git a/src/components/admin/studio/organize-imports-dialog.tsx b/src/components/admin/studio/organize-imports-dialog.tsx index 0474552b..4748ba29 100644 --- a/src/components/admin/studio/organize-imports-dialog.tsx +++ b/src/components/admin/studio/organize-imports-dialog.tsx @@ -7,7 +7,9 @@ import { Loader2, Plus, RefreshCw, + Search, Wand2, + X, } from "lucide-react"; import { useRouter } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -84,6 +86,17 @@ export function OrganizeImportsDialog({ ); const [parentId, setParentId] = useState(-1); + const [moveMode, setMoveMode] = useState(false); + const [destinationPageId, setDestinationPageId] = useState( + null, + ); + const [destinationCaption, setDestinationCaption] = useState( + null, + ); + const [destQuery, setDestQuery] = useState(""); + const [destDebounced, setDestDebounced] = useState(""); + const [destResults, setDestResults] = useState([]); + const [destSearchLoading, setDestSearchLoading] = useState(false); const [approved, setApproved] = useState>({}); const [overrides, setOverrides] = useState< Record @@ -135,6 +148,12 @@ export function OrganizeImportsDialog({ setApproved({}); setOverrides({}); setParentId(-1); + setMoveMode(false); + setDestinationPageId(null); + setDestinationCaption(null); + setDestQuery(""); + setDestDebounced(""); + setDestResults([]); setFreeMode(true); setCredits(0); setPoints(0); @@ -142,6 +161,43 @@ export function OrganizeImportsDialog({ void load(); }, [open, load]); + useEffect(() => { + const t = setTimeout(() => setDestDebounced(destQuery.trim()), 300); + return () => clearTimeout(t); + }, [destQuery]); + + useEffect(() => { + if (!moveMode || destDebounced.length < 2) { + if (!moveMode) setDestResults([]); + return; + } + setDestSearchLoading(true); + const ac = new AbortController(); + fetch( + `/api/admin/catalog/tree?search=${encodeURIComponent(destDebounced)}`, + { + signal: ac.signal, + }, + ) + .then((r) => r.json()) + .then((data) => { + const pages = Array.isArray(data?.pages) ? data.pages : []; + setDestResults( + pages + .filter((p: { id: number }) => Number(p.id) > 0) + .map((p: { id: number; caption: string }) => ({ + id: Number(p.id), + caption: String(p.caption ?? p.id), + })), + ); + }) + .catch((error) => + console.error("[OrganizeImports] page search failed:", error), + ) + .finally(() => setDestSearchLoading(false)); + return () => ac.abort(); + }, [moveMode, destDebounced]); + const groups = useMemo(() => groupSuggestedCategories(items), [items]); const allApproved = @@ -175,14 +231,21 @@ export function OrganizeImportsDialog({ const handleCreate = async () => { if (isCreating) return; + if (moveMode && destinationPageId == null) { + toast.error(t("chooseDestination")); + return; + } const payload = groups .filter((g) => approved[g.name]) .map((g) => { const o = overrides[g.name] ?? {}; return { - caption: (o.caption ?? g.name).trim(), + caption: moveMode + ? (destinationCaption ?? "Imported") + : (o.caption ?? g.name).trim(), pageLayout: o.layout ?? g.layout?.layout ?? "default_3x3", iconImage: o.icon ?? g.icon, + destinationPageId: moveMode ? destinationPageId : null, items: g.items.map((item) => ({ itemId: item.id, catalogItemId: item.catalogItemId, @@ -201,9 +264,19 @@ export function OrganizeImportsDialog({ 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"}`, - ); + if (moveMode) { + const moved = created.reduce((sum, c) => sum + c.moved, 0); + const added = created.reduce((sum, c) => sum + c.added, 0); + toast.success( + `Moved ${moved} offer${moved === 1 ? "" : "s"}${ + added > 0 ? `, added ${added} new` : "" + } to "${destinationCaption ?? "page"}"`, + ); + } else { + toast.success( + `Created ${created.length} page${created.length === 1 ? "" : "s"}`, + ); + } onOpenChange(false); router.refresh(); } catch (error) { @@ -303,8 +376,113 @@ export function OrganizeImportsDialog({ )} - {/* Parent */} + {/* Mode: create new pages vs move into an existing page */} {!loading && items.length > 0 && ( +
+ + {t("mode")} + + + +
+ )} + + {/* Destination page (move mode) */} + {moveMode && !loading && items.length > 0 && ( +
+ {t("destination")} + {destinationPageId != null ? ( +
+ + + {destinationCaption} + + + {t("destinationChosen")} + + +
+ ) : ( +
+ + setDestQuery(e.target.value)} + /> + {destSearchLoading && ( + + )} +
+ )} + {destinationPageId == null && destDebounced.length >= 2 && ( +
+ {destResults.length === 0 && !destSearchLoading ? ( +

+ {t("noDestResults")} +

+ ) : ( + destResults.map((p) => ( + + )) + )} +
+ )} +

+ {t("destinationHint")} +

+
+ )} + + {/* Parent */} + {!moveMode && !loading && items.length > 0 && (
{t("parent")} e.stopPropagation()} - onChange={(e) => - updateGroup(group.name, { - caption: e.target.value, - }) - } - /> - {caption !== group.name && ( - - )} -
- -
- - {t("icon")} - - - updateGroup(group.name, { icon: id }) - } - > - - - {icon !== group.icon && ( - - )} -
- -
- - {t("layout")} - -
- {CATALOG_LAYOUTS.map((l) => { - const color = - LAYOUT_COLORS[l] || - "bg-muted text-muted-foreground"; - return ( - - ); - })} -
- {group.layout && - layout !== group.layout.layout && - group.layout.layout !== "default_3x3" && ( + onChange={(e) => + updateGroup(group.name, { + caption: e.target.value, + }) + } + /> + {caption !== group.name && ( )} +
+ +
+ + {t("icon")} + + + updateGroup(group.name, { icon: id }) + } + > + + + {icon !== group.icon && ( + + )} +
+ +
+ + {t("layout")} + +
+ {CATALOG_LAYOUTS.map((l) => { + const color = + LAYOUT_COLORS[l] || + "bg-muted text-muted-foreground"; + return ( + + ); + })} +
+ {group.layout && + layout !== group.layout.layout && + group.layout.layout !== "default_3x3" && ( + + )} +
- + )}
{group.items.slice(0, 12).map((item) => ( @@ -621,7 +803,12 @@ export function OrganizeImportsDialog({ type="button" size="sm" className="gap-1" - disabled={loading || isCreating || totalSelected === 0} + disabled={ + loading || + isCreating || + totalSelected === 0 || + (moveMode && destinationPageId == null) + } onClick={() => void handleCreate()} > {isCreating ? ( @@ -629,7 +816,9 @@ export function OrganizeImportsDialog({ ) : ( )} - {t("create", { count: totalSelected })} + {moveMode + ? t("move", { count: totalSelected }) + : t("create", { count: totalSelected })}
diff --git a/src/messages/en.json b/src/messages/en.json index b3167892..72e48392 100644 --- a/src/messages/en.json +++ b/src/messages/en.json @@ -2209,6 +2209,16 @@ "desc": "Imported furniture is grouped into suggested pages. Review each group, then create the pages you approve.", "selectAll": "Select all", "deselectAll": "Deselect all", + "mode": "Mode", + "modeCreate": "Create new pages", + "modeMove": "Move into an existing page", + "destination": "Destination page", + "destinationChosen": "selected", + "destinationSearchPlaceholder": "Search pages by name…", + "noDestResults": "No pages found", + "destinationHint": "All approved groups are moved into this page — no new page is created.", + "chooseDestination": "Pick a destination page first", + "move": "Move {count, plural, one {1 item} other {# items}}", "name": "Name", "icon": "Icon", "layout": "Layout", diff --git a/src/messages/nl.json b/src/messages/nl.json index 1dfa8710..78220f63 100644 --- a/src/messages/nl.json +++ b/src/messages/nl.json @@ -1977,6 +1977,16 @@ "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", + "mode": "Modus", + "modeCreate": "Nieuwe pagina's aanmaken", + "modeMove": "Verplaatsen naar bestaande pagina", + "destination": "Doelpagina", + "destinationChosen": "geselecteerd", + "destinationSearchPlaceholder": "Zoek pagina's op naam…", + "noDestResults": "Geen pagina's gevonden", + "destinationHint": "Alle goedgekeurde groepen worden naar deze pagina verplaatst — er wordt geen nieuwe pagina aangemaakt.", + "chooseDestination": "Kies eerst een doelpagina", + "move": "{count, plural, one {1 item verplaatsen} other {# items verplaatsen}}", "name": "Naam", "icon": "Icoon", "layout": "Indeling",