feat(studio): allow moving imports into an existing page instead of creating ones
CI / check (push) Successful in 2m31s
CI / deploy (push) Successful in 1m19s
CI / publish-container (push) Successful in 45s

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
This commit is contained in:
openhands committed 2026-09-11 20:44:26 +02:00
1 parent 61c7519bea
commit caeb4eb0b3
4 files changed
+395 -148

No files matched your search

+63 -25
View File
@@ -355,6 +355,8 @@ export interface OrganizeImportGroup {
caption: string; caption: string;
pageLayout: string; pageLayout: string;
iconImage: number; iconImage: number;
/** When set, items are moved into this existing page instead of creating one. */
destinationPageId?: number | null;
items: Array<{ items: Array<{
itemId: number; itemId: number;
catalogItemId: number | null; catalogItemId: number | null;
@@ -420,25 +422,54 @@ export async function organizeImportFurni(input: {
let totalAdded = 0; let totalAdded = 0;
for (const group of groups) { for (const group of groups) {
const pageId = Number( // Either create a fresh page or reuse an existing destination.
await createPageCommand("normal", { const destinationPageId = group.destinationPageId
caption: group.caption, ? Number(group.destinationPageId)
parentId: input.parentId, : null;
pageLayout: group.pageLayout || "default_3x3", if (destinationPageId != null && !Number.isFinite(destinationPageId)) {
captionSave: group.caption.slice(0, 25), return {
iconImage: group.iconImage ?? 0, ok: false as const,
iconColor: 0, error: "Invalid destination page id",
minRank: 1, };
orderNum: 0, }
visible: "1", const pageId =
enabled: "1", destinationPageId ??
clubOnly: "0", Number(
vipOnly: "0", await createPageCommand("normal", {
pageHeadline: "", caption: group.caption,
pageTeaser: "", parentId: input.parentId,
includes: "", 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 baseIds = [...new Set(group.items.map((r) => r.itemId))];
const bases = await db const bases = await db
@@ -475,7 +506,6 @@ export async function organizeImportFurni(input: {
} }
} }
let nextOrder = 1;
let moved = 0; let moved = 0;
let added = 0; let added = 0;
@@ -529,11 +559,17 @@ export async function organizeImportFurni(input: {
} }
if (moveIds.length > 0) { 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` await db.execute(sql`
UPDATE catalog_items UPDATE catalog_items
SET page_id = ${pageId}, SET page_id = ${pageId},
order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END, order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END
catalog_name = CASE id ${sql.raw(moveCaseName.join(" "))} END ${nameUpdate}
WHERE id IN (${sql.join( WHERE id IN (${sql.join(
moveIds.map((id) => sql`${id}`), moveIds.map((id) => sql`${id}`),
sql`, `, sql`, `,
@@ -543,12 +579,14 @@ export async function organizeImportFurni(input: {
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "catalog_page_create", action: destinationPageId
description: `Organized import into page #${pageId} "${group.caption}" (${moved} moved, ${added} added)`, ? "catalog_items_bulk_update"
: "catalog_page_create",
description: `Organized import into page #${pageId} "${caption}" (${moved} moved, ${added} added)`,
targetType: "catalog_page", targetType: "catalog_page",
targetId: pageId, targetId: pageId,
}); });
created.push({ pageId, caption: group.caption, moved, added }); created.push({ pageId, caption, moved, added });
totalMoved += moved; totalMoved += moved;
totalAdded += added; totalAdded += added;
} }
@@ -7,7 +7,9 @@ import {
Loader2, Loader2,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
Wand2, Wand2,
X,
} from "lucide-react"; } from "lucide-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
@@ -84,6 +86,17 @@ export function OrganizeImportsDialog({
); );
const [parentId, setParentId] = useState(-1); const [parentId, setParentId] = useState(-1);
const [moveMode, setMoveMode] = useState(false);
const [destinationPageId, setDestinationPageId] = useState<number | null>(
null,
);
const [destinationCaption, setDestinationCaption] = useState<string | null>(
null,
);
const [destQuery, setDestQuery] = useState("");
const [destDebounced, setDestDebounced] = useState("");
const [destResults, setDestResults] = useState<RootPage[]>([]);
const [destSearchLoading, setDestSearchLoading] = useState(false);
const [approved, setApproved] = useState<Record<string, boolean>>({}); const [approved, setApproved] = useState<Record<string, boolean>>({});
const [overrides, setOverrides] = useState< const [overrides, setOverrides] = useState<
Record<string, OrganizeGroupOverride> Record<string, OrganizeGroupOverride>
@@ -135,6 +148,12 @@ export function OrganizeImportsDialog({
setApproved({}); setApproved({});
setOverrides({}); setOverrides({});
setParentId(-1); setParentId(-1);
setMoveMode(false);
setDestinationPageId(null);
setDestinationCaption(null);
setDestQuery("");
setDestDebounced("");
setDestResults([]);
setFreeMode(true); setFreeMode(true);
setCredits(0); setCredits(0);
setPoints(0); setPoints(0);
@@ -142,6 +161,43 @@ export function OrganizeImportsDialog({
void load(); void load();
}, [open, 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 groups = useMemo(() => groupSuggestedCategories(items), [items]);
const allApproved = const allApproved =
@@ -175,14 +231,21 @@ export function OrganizeImportsDialog({
const handleCreate = async () => { const handleCreate = async () => {
if (isCreating) return; if (isCreating) return;
if (moveMode && destinationPageId == null) {
toast.error(t("chooseDestination"));
return;
}
const payload = groups const payload = groups
.filter((g) => approved[g.name]) .filter((g) => approved[g.name])
.map((g) => { .map((g) => {
const o = overrides[g.name] ?? {}; const o = overrides[g.name] ?? {};
return { return {
caption: (o.caption ?? g.name).trim(), caption: moveMode
? (destinationCaption ?? "Imported")
: (o.caption ?? g.name).trim(),
pageLayout: o.layout ?? g.layout?.layout ?? "default_3x3", pageLayout: o.layout ?? g.layout?.layout ?? "default_3x3",
iconImage: o.icon ?? g.icon, iconImage: o.icon ?? g.icon,
destinationPageId: moveMode ? destinationPageId : null,
items: g.items.map((item) => ({ items: g.items.map((item) => ({
itemId: item.id, itemId: item.id,
catalogItemId: item.catalogItemId, catalogItemId: item.catalogItemId,
@@ -201,9 +264,19 @@ export function OrganizeImportsDialog({
const result = await organizeImportFurni({ parentId, groups: payload }); const result = await organizeImportFurni({ parentId, groups: payload });
if (!result.ok) throw new Error(result.error ?? "Failed"); if (!result.ok) throw new Error(result.error ?? "Failed");
const created = result.data?.created ?? []; const created = result.data?.created ?? [];
toast.success( if (moveMode) {
`Organized ${created.length} import page${created.length === 1 ? "" : "s"}`, 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); onOpenChange(false);
router.refresh(); router.refresh();
} catch (error) { } catch (error) {
@@ -303,8 +376,113 @@ export function OrganizeImportsDialog({
</div> </div>
)} )}
{/* Parent */} {/* Mode: create new pages vs move into an existing page */}
{!loading && items.length > 0 && ( {!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("mode")}
</span>
<Button
type="button"
variant={!moveMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => {
setMoveMode(false);
setDestinationPageId(null);
setDestinationCaption(null);
}}
>
{t("modeCreate")}
</Button>
<Button
type="button"
variant={moveMode ? "default" : "outline"}
size="sm"
className="h-6 px-2 text-[10px]"
onClick={() => setMoveMode(true)}
>
{t("modeMove")}
</Button>
</div>
)}
{/* Destination page (move mode) */}
{moveMode && !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)]">
<span className="text-[10px]">{t("destination")}</span>
{destinationPageId != null ? (
<div className="flex items-center gap-2 rounded border border-[var(--admin-accent)]/40 bg-[var(--admin-accent-subtle)] px-2 py-1.5">
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-sm font-semibold">
{destinationCaption}
</span>
<Badge variant="secondary" className="h-4 text-[9px]">
{t("destinationChosen")}
</Badge>
<button
type="button"
onClick={() => {
setDestinationPageId(null);
setDestinationCaption(null);
setDestQuery("");
}}
className="text-muted-foreground hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
className="h-8 pl-8 text-xs"
placeholder={t("destinationSearchPlaceholder")}
value={destQuery}
onChange={(e) => setDestQuery(e.target.value)}
/>
{destSearchLoading && (
<Loader2 className="absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
)}
</div>
)}
{destinationPageId == null && destDebounced.length >= 2 && (
<div className="max-h-[160px] divide-y overflow-y-auto rounded border">
{destResults.length === 0 && !destSearchLoading ? (
<p className="py-3 text-center text-[10px] text-muted-foreground">
{t("noDestResults")}
</p>
) : (
destResults.map((p) => (
<button
key={p.id}
type="button"
onClick={() => {
setDestinationPageId(p.id);
setDestinationCaption(p.caption);
}}
className="flex w-full items-center gap-2 px-2 py-1.5 text-left hover:bg-accent"
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="min-w-0 flex-1 truncate text-xs">
{p.caption}
</span>
<span className="font-mono text-[9px] text-muted-foreground">
#{p.id}
</span>
</button>
))
)}
</div>
)}
<p className="text-[10px] text-muted-foreground">
{t("destinationHint")}
</p>
</div>
)}
{/* Parent */}
{!moveMode && !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)]"> <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")} {t("parent")}
<Select <Select
@@ -438,137 +616,141 @@ export function OrganizeImportsDialog({
)} )}
</div> </div>
<div className="mt-3 space-y-2 border-t pt-2 text-xs font-medium text-[var(--admin-text-muted)]"> {!moveMode && (
<div className="flex items-center gap-1"> <div className="mt-3 space-y-2 border-t pt-2 text-xs font-medium text-[var(--admin-text-muted)]">
<span className="w-12 shrink-0 text-[10px]"> <div className="flex items-center gap-1">
{t("name")} <span className="w-12 shrink-0 text-[10px]">
</span> {t("name")}
<Input </span>
className="h-7 text-xs" <Input
value={caption} className="h-7 text-xs"
onClick={(e) => e.stopPropagation()} value={caption}
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()} onClick={(e) => e.stopPropagation()}
> onChange={(e) =>
<CatalogIcon iconImage={icon} size={16} /> updateGroup(group.name, {
<span className="font-mono text-[10px] text-muted-foreground"> caption: e.target.value,
#{icon} })
</span> }
</Button> />
</IconPicker> {caption !== group.name && (
{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 <Button
type="button" type="button"
variant="ghost" variant="outline"
size="sm" 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)]" className="h-7 shrink-0 px-2 text-[10px]"
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
updateGroup(group.name, { updateGroup(group.name, {
layout: group.layout?.layout, caption: group.name,
}); });
}} }}
> >
<Wand2 className="mr-0.5 h-2.5 w-2.5" /> <Wand2 className="mr-0.5 h-2.5 w-2.5" />
{t("suggestedLayout", { {t("resetSuggestion")}
layout: group.layout.layout,
})}
</Button> </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>
</div> )}
<div className="mt-2 flex flex-wrap gap-1.5 border-t pt-2"> <div className="mt-2 flex flex-wrap gap-1.5 border-t pt-2">
{group.items.slice(0, 12).map((item) => ( {group.items.slice(0, 12).map((item) => (
@@ -621,7 +803,12 @@ export function OrganizeImportsDialog({
type="button" type="button"
size="sm" size="sm"
className="gap-1" className="gap-1"
disabled={loading || isCreating || totalSelected === 0} disabled={
loading ||
isCreating ||
totalSelected === 0 ||
(moveMode && destinationPageId == null)
}
onClick={() => void handleCreate()} onClick={() => void handleCreate()}
> >
{isCreating ? ( {isCreating ? (
@@ -629,7 +816,9 @@ export function OrganizeImportsDialog({
) : ( ) : (
<Plus className="h-3 w-3" /> <Plus className="h-3 w-3" />
)} )}
{t("create", { count: totalSelected })} {moveMode
? t("move", { count: totalSelected })
: t("create", { count: totalSelected })}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
+10
View File
@@ -2209,6 +2209,16 @@
"desc": "Imported furniture is grouped into suggested pages. Review each group, then create the pages you approve.", "desc": "Imported furniture is grouped into suggested pages. Review each group, then create the pages you approve.",
"selectAll": "Select all", "selectAll": "Select all",
"deselectAll": "Deselect 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", "name": "Name",
"icon": "Icon", "icon": "Icon",
"layout": "Layout", "layout": "Layout",
+10
View File
@@ -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.", "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", "selectAll": "Alles selecteren",
"deselectAll": "Alles deselecteren", "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", "name": "Naam",
"icon": "Icoon", "icon": "Icoon",
"layout": "Indeling", "layout": "Indeling",