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
This commit is contained in:
1 parent
61c7519bea
commit
caeb4eb0b3
4 files changed
+395
-148
No files matched your search
@@ -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<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 [overrides, setOverrides] = useState<
|
||||
Record<string, OrganizeGroupOverride>
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Parent */}
|
||||
{/* Mode: create new pages vs move into an existing page */}
|
||||
{!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)]">
|
||||
{t("parent")}
|
||||
<Select
|
||||
@@ -438,137 +616,141 @@ export function OrganizeImportsDialog({
|
||||
)}
|
||||
</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"
|
||||
{!moveMode && (
|
||||
<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()}
|
||||
>
|
||||
<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" && (
|
||||
onChange={(e) =>
|
||||
updateGroup(group.name, {
|
||||
caption: e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
{caption !== group.name && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
variant="outline"
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
updateGroup(group.name, {
|
||||
layout: group.layout?.layout,
|
||||
caption: group.name,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Wand2 className="mr-0.5 h-2.5 w-2.5" />
|
||||
{t("suggestedLayout", {
|
||||
layout: group.layout.layout,
|
||||
})}
|
||||
{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>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1.5 border-t pt-2">
|
||||
{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({
|
||||
) : (
|
||||
<Plus className="h-3 w-3" />
|
||||
)}
|
||||
{t("create", { count: totalSelected })}
|
||||
{moveMode
|
||||
? t("move", { count: totalSelected })
|
||||
: t("create", { count: totalSelected })}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
Reference in new issue
Block a user