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",