feat(catalog): add reviewed bulk edits and complete category duplication
This commit is contained in:
1 parent
0bedc04692
commit
55a47949f1
26 files changed
+2504
-85
No files matched your search
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { BulkOfferInput } from "@/features/catalog/domain/bulk-offers";
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
previewBulkOffersCommand,
|
||||
} from "@/features/catalog/server/bulk-offers";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
export async function previewBulkOffers(input: BulkOfferInput) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return { ok: true as const, data: await previewBulkOffersCommand(input) };
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
export async function applyBulkOffers(
|
||||
input: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
try {
|
||||
return await withCatalogExport(async () => {
|
||||
const data = await applyBulkOffersCommand(input, fingerprint);
|
||||
if (data.changedCount > 0) {
|
||||
await logAudit({
|
||||
userId: staff.id,
|
||||
action: "catalog_offers_bulk_update",
|
||||
target: "catalog",
|
||||
after: {
|
||||
ids: input.ids,
|
||||
changes: input.changes,
|
||||
changedCount: data.changedCount,
|
||||
},
|
||||
}).catch((error) =>
|
||||
logger.error("Catalog bulk update committed; audit write failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
}),
|
||||
);
|
||||
await sendCatalogUpdate();
|
||||
revalidatePath("/admin/catalog", "layout");
|
||||
}
|
||||
return { ok: true as const, data };
|
||||
});
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function getBulkOfferDestinations() {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: await listBulkOfferDestinationsCommand(),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
"use server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type {
|
||||
DuplicateInput,
|
||||
DuplicatePreview,
|
||||
} from "@/features/catalog/domain/duplicate";
|
||||
import {
|
||||
duplicateCategoryCommand,
|
||||
duplicateDestinationsCommand,
|
||||
previewDuplicateCommand,
|
||||
} from "@/features/catalog/server/duplicate-commands";
|
||||
import { catalogFailure } from "@/features/catalog/server/errors";
|
||||
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
|
||||
import { requirePermission } from "@/lib/admin/guard";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { withCatalogExport } from "@/lib/services/catalog-git-queue";
|
||||
import { logStaffActivity } from "@/lib/services/staff-activity";
|
||||
export async function previewCatalogDuplicate(input: DuplicateInput) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return { ok: true as const, data: await previewDuplicateCommand(input) };
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
export async function applyCatalogDuplicate(input: DuplicatePreview) {
|
||||
const staff = await requirePermission(PERMS.CATALOG_EDIT);
|
||||
return withCatalogExport(async () => {
|
||||
let data: Awaited<ReturnType<typeof duplicateCategoryCommand>>;
|
||||
try {
|
||||
data = await duplicateCategoryCommand(input);
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
const hotel = await sendCatalogUpdate();
|
||||
await logStaffActivity({
|
||||
staffId: staff.id,
|
||||
action: "catalog_page_create",
|
||||
description: `Duplicated ${input.kind} category #${input.sourceId} to #${data.id}: ${data.pages} categories, ${data.offers} offers`,
|
||||
targetType: "catalog_page",
|
||||
targetId: data.id,
|
||||
}).catch((error) => {
|
||||
logger.error("Category duplicated but audit logging failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
});
|
||||
revalidatePath("/admin/catalog");
|
||||
return { ok: true as const, data: { ...data, hotel } };
|
||||
});
|
||||
}
|
||||
|
||||
export async function catalogDuplicateDestinations(
|
||||
kind: DuplicateInput["kind"],
|
||||
) {
|
||||
await requirePermission(PERMS.CATALOG_VIEW);
|
||||
try {
|
||||
return {
|
||||
ok: true as const,
|
||||
data: await duplicateDestinationsCommand(kind),
|
||||
};
|
||||
} catch (error) {
|
||||
return { ok: false as const, error: catalogFailure(error).message };
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
X,
|
||||
} 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 {
|
||||
@@ -80,6 +81,7 @@ import {
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { BulkOfferEditor } from "@/features/catalog/components/bulk-offer-editor";
|
||||
import { useServerAction } from "@/hooks/use-server-action";
|
||||
import { getSuggestions, recordMove } from "@/lib/move-suggestions";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -167,6 +169,10 @@ export function CatalogItemsTable({
|
||||
allPages,
|
||||
}: CatalogItemsTableProps) {
|
||||
const router = useRouter();
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const [bulkRefreshFrom, setBulkRefreshFrom] = useState<
|
||||
CatalogItemData[] | null
|
||||
>(null);
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
@@ -294,7 +300,26 @@ export function CatalogItemsTable({
|
||||
return itemOrder.some((id, idx) => id !== originalOrder[idx]);
|
||||
}, [items, itemOrder]);
|
||||
|
||||
const hasUnsavedChanges = modifiedItems.length > 0 || hasOrderChanges;
|
||||
const hasUnsavedChanges =
|
||||
bulkRefreshFrom === null && (modifiedItems.length > 0 || hasOrderChanges);
|
||||
useEffect(() => {
|
||||
if (bulkRefreshFrom === null || items === bulkRefreshFrom) return;
|
||||
setInlineEdits(
|
||||
Object.fromEntries(
|
||||
items.map((item) => [
|
||||
item.id,
|
||||
{
|
||||
costCredits: item.costCredits,
|
||||
costPoints: item.costPoints,
|
||||
pointsType: item.pointsType,
|
||||
amount: item.amount,
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
setItemOrder(items.map((item) => item.id));
|
||||
setBulkRefreshFrom(null);
|
||||
}, [items, bulkRefreshFrom]);
|
||||
useEffect(() => {
|
||||
onDirtyChange?.(hasUnsavedChanges);
|
||||
}, [hasUnsavedChanges, onDirtyChange]);
|
||||
@@ -904,7 +929,19 @@ export function CatalogItemsTable({
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
<Card>
|
||||
{bulkRefreshFrom !== null && (
|
||||
<p
|
||||
role="status"
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{bulk("refreshing")}
|
||||
</p>
|
||||
)}
|
||||
<Card
|
||||
inert={bulkRefreshFrom !== null}
|
||||
aria-busy={bulkRefreshFrom !== null}
|
||||
>
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex flex-row items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
@@ -958,6 +995,21 @@ export function CatalogItemsTable({
|
||||
<>
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<BulkOfferEditor
|
||||
ids={[...selected]}
|
||||
allPages={allPages}
|
||||
disabled={
|
||||
hasUnsavedChanges ||
|
||||
isPending ||
|
||||
savingItemId !== null
|
||||
}
|
||||
onApplied={() => {
|
||||
setBulkRefreshFrom(items);
|
||||
setSelected(new Set());
|
||||
if (onRefresh) onRefresh();
|
||||
else router.refresh();
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
@@ -151,6 +151,8 @@ export function MovePageCombobox({
|
||||
pages,
|
||||
currentPageId,
|
||||
suggestedPageIds,
|
||||
disabled = false,
|
||||
labels,
|
||||
}: {
|
||||
value: number | null;
|
||||
onChange: (id: number | null) => void;
|
||||
@@ -158,6 +160,14 @@ export function MovePageCombobox({
|
||||
currentPageId: number;
|
||||
/** Page IDs to show first as suggestions (from move-suggestions.ts) */
|
||||
suggestedPageIds?: number[];
|
||||
disabled?: boolean;
|
||||
labels?: {
|
||||
placeholder: string;
|
||||
search: string;
|
||||
empty: string;
|
||||
suggested: string;
|
||||
all: string;
|
||||
};
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -194,6 +204,7 @@ export function MovePageCombobox({
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
disabled={disabled}
|
||||
aria-expanded={open}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
@@ -205,7 +216,9 @@ export function MovePageCombobox({
|
||||
{selectedPage.caption}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Select target page...</span>
|
||||
<span className="text-muted-foreground">
|
||||
{labels?.placeholder ?? "Select target page..."}
|
||||
</span>
|
||||
)}
|
||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
@@ -215,13 +228,13 @@ export function MovePageCombobox({
|
||||
align="start"
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search pages..." />
|
||||
<CommandInput placeholder={labels?.search ?? "Search pages..."} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No page found.</CommandEmpty>
|
||||
<CommandEmpty>{labels?.empty ?? "No page found."}</CommandEmpty>
|
||||
|
||||
{/* Suggested pages */}
|
||||
{suggested.length > 0 && (
|
||||
<CommandGroup heading="Suggested">
|
||||
<CommandGroup heading={labels?.suggested ?? "Suggested"}>
|
||||
{suggested.map((page) => (
|
||||
<CommandItem
|
||||
key={`s-${page.id}`}
|
||||
@@ -249,7 +262,9 @@ export function MovePageCombobox({
|
||||
|
||||
{/* All pages */}
|
||||
<CommandGroup
|
||||
heading={suggested.length > 0 ? "All pages" : undefined}
|
||||
heading={
|
||||
suggested.length > 0 ? (labels?.all ?? "All pages") : undefined
|
||||
}
|
||||
>
|
||||
{remaining.map((page) => (
|
||||
<CommandItem
|
||||
|
||||
@@ -263,7 +263,7 @@ export default async function CatalogPagesPage({
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="mb-4 space-y-3">
|
||||
<CatalogSyncStatus canEdit={canEdit} />
|
||||
<CatalogSearch catalogType={catalogType} />
|
||||
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -496,7 +496,7 @@ export default async function CatalogPagesPage({
|
||||
<AdminPageShell icon={Store} title={t("title")} subtitle={t("subtitle")}>
|
||||
<div className="mb-4 space-y-3">
|
||||
<CatalogSyncStatus canEdit={canEdit} />
|
||||
<CatalogSearch catalogType={catalogType} />
|
||||
<CatalogSearch catalogType={catalogType} canEdit={canEdit} />
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -116,13 +116,13 @@ export function CatalogManagerDialog({
|
||||
</Button>
|
||||
{mounted && open
|
||||
? createPortal(
|
||||
<div className="fixed inset-0 z-[200]">
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-foreground/70" />
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="visual-catalog-manager-title"
|
||||
className="fixed top-[2.5vh] left-[1vw] z-[201] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
|
||||
className="fixed top-[2.5vh] left-[1vw] flex h-[95vh] max-h-[95vh] w-[98vw] max-w-[98vw] flex-col overflow-hidden rounded-lg border border-[var(--admin-border)] bg-[var(--admin-canvas)] text-[var(--admin-text)] shadow-2xl"
|
||||
style={{ backgroundColor: "var(--admin-canvas)" }}
|
||||
>
|
||||
<CatalogManagerProvider
|
||||
|
||||
@@ -17,7 +17,16 @@ import {
|
||||
useSortable,
|
||||
} from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { Loader2, Pencil, Plus, Power, PowerOff, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Loader2,
|
||||
Pencil,
|
||||
Plus,
|
||||
Power,
|
||||
PowerOff,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -41,6 +50,8 @@ import {
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCatalogManager } from "./use-catalog-store";
|
||||
|
||||
@@ -67,8 +78,12 @@ export function RootTabs({
|
||||
loading,
|
||||
onMutation,
|
||||
}: RootTabsProps) {
|
||||
const { catalogType, canEdit } = useCatalogManager();
|
||||
const { catalogType, canEdit, state } = useCatalogManager();
|
||||
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
|
||||
null,
|
||||
);
|
||||
const [activeDragId, setActiveDragId] = useState<number | null>(null);
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
@@ -158,6 +173,13 @@ export function RootTabs({
|
||||
style={{ backgroundColor: "var(--admin-surface)" }}
|
||||
>
|
||||
{confirmDialog}
|
||||
{duplicateSource && (
|
||||
<DuplicateCategoryDialog
|
||||
source={duplicateSource}
|
||||
onClose={() => setDuplicateSource(null)}
|
||||
onComplete={() => onMutation?.()}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground mx-2" />
|
||||
)}
|
||||
@@ -180,6 +202,19 @@ export function RootTabs({
|
||||
onEdit={() => onEditTab(tab.id)}
|
||||
onToggle={() => handleToggle(tab)}
|
||||
onDelete={() => handleDelete(tab)}
|
||||
onDuplicate={() => {
|
||||
if (state.editorDirty) {
|
||||
toast.error(t("draftBlocked"));
|
||||
return;
|
||||
}
|
||||
if (canEdit && requestCatalogNavigation())
|
||||
setDuplicateSource({
|
||||
kind: catalogType,
|
||||
sourceId: tab.id,
|
||||
parentId: -1,
|
||||
name: `${tab.caption.slice(0, 121)} (copy)`,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -218,6 +253,7 @@ function SortableRootTab({
|
||||
onEdit,
|
||||
onToggle,
|
||||
onDelete,
|
||||
onDuplicate,
|
||||
}: {
|
||||
tab: RootTab;
|
||||
isActive: boolean;
|
||||
@@ -225,6 +261,7 @@ function SortableRootTab({
|
||||
onEdit: () => void;
|
||||
onToggle: () => void;
|
||||
onDelete: () => void;
|
||||
onDuplicate: () => void;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
@@ -237,6 +274,8 @@ function SortableRootTab({
|
||||
id: tab.id,
|
||||
});
|
||||
|
||||
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
||||
const { canEdit } = useCatalogManager();
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -256,6 +295,12 @@ function SortableRootTab({
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-44">
|
||||
{canEdit && (
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<Copy className="mr-2 h-3.5 w-3.5" />
|
||||
{t("title")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
Edit page
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
@@ -39,7 +40,6 @@ import {
|
||||
reorderCatalogPages,
|
||||
reorderTreePage,
|
||||
toggleCatalogPage,
|
||||
updateCatalogPage,
|
||||
} from "@/actions/catalog";
|
||||
import {
|
||||
createBcPage,
|
||||
@@ -47,7 +47,6 @@ import {
|
||||
reorderBcCatalogPages,
|
||||
reorderBcTreePage,
|
||||
toggleBcPage,
|
||||
updateBcPage,
|
||||
} from "@/actions/catalog-bc";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -58,6 +57,9 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
|
||||
import { DuplicateCategoryDialog } from "@/features/catalog/components/duplicate-category-dialog";
|
||||
import type { DuplicateInput } from "@/features/catalog/domain/duplicate";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
type FlatTreeNode,
|
||||
@@ -93,6 +95,7 @@ export function SortableTree({
|
||||
nodeUpdate,
|
||||
}: SortableTreeProps) {
|
||||
const { state, dispatch, catalogType, canEdit } = useCatalogManager();
|
||||
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
||||
const catQs = catalogTreeQs(catalogType);
|
||||
const pageBase =
|
||||
catalogType === "bc" ? "/admin/catalog/builder-club" : "/admin/catalog";
|
||||
@@ -107,6 +110,9 @@ export function SortableTree({
|
||||
sortMode,
|
||||
} = state;
|
||||
|
||||
const [duplicateSource, setDuplicateSource] = useState<DuplicateInput | null>(
|
||||
null,
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [searchResults, setSearchResults] = useState<TreeNodeData[] | null>(
|
||||
null,
|
||||
@@ -331,6 +337,7 @@ export function SortableTree({
|
||||
const handleDeleteRef = useRef<(id: number) => Promise<void>>(async () => {});
|
||||
useEffect(() => {
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if ((e.target as HTMLElement)?.closest('[role="dialog"]')) return;
|
||||
const tag = (e.target as HTMLElement)?.tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") {
|
||||
if (e.key === "/") {
|
||||
@@ -590,67 +597,24 @@ export function SortableTree({
|
||||
};
|
||||
handleDeleteRef.current = handleDelete;
|
||||
|
||||
const handleDuplicate = async (id: number) => {
|
||||
if (!canEdit) return;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/catalog/tree?pageId=${id}${catQs}`);
|
||||
if (!res.ok) throw new Error();
|
||||
const { page } = await res.json();
|
||||
const result =
|
||||
catalogType === "bc"
|
||||
? await createBcPage({
|
||||
parentId: page.parentId ?? -1,
|
||||
caption: `${page.caption} (copy)`,
|
||||
pageLayout: page.pageLayout ?? "default_3x3",
|
||||
iconImage: page.iconImage ?? 0,
|
||||
iconColor: page.iconColor ?? 0,
|
||||
enabled: "0",
|
||||
visible: page.visible ?? "1",
|
||||
orderNum: (page.orderNum ?? 0) + 1,
|
||||
})
|
||||
: await createCatalogPage({
|
||||
parentId: page.parentId ?? -1,
|
||||
caption: `${page.caption} (copy)`,
|
||||
pageLayout: page.pageLayout ?? "default_3x3",
|
||||
iconImage: page.iconImage ?? 0,
|
||||
iconColor: page.iconColor ?? 0,
|
||||
enabled: "0",
|
||||
visible: page.visible ?? "1",
|
||||
minRank: page.minRank ?? 1,
|
||||
orderNum: (page.orderNum ?? 0) + 1,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
const newId = (result.data as Record<string, unknown>).id as number;
|
||||
if (catalogType === "bc") {
|
||||
await updateBcPage({
|
||||
id: newId,
|
||||
pageHeadline: page.pageHeadline ?? "",
|
||||
pageTeaser: page.pageTeaser ?? "",
|
||||
pageText1: page.pageText1,
|
||||
pageText2: page.pageText2,
|
||||
pageTextDetails: page.pageTextDetails,
|
||||
pageTextTeaser: page.pageTextTeaser,
|
||||
});
|
||||
} else {
|
||||
await updateCatalogPage({
|
||||
id: newId,
|
||||
pageHeadline: page.pageHeadline ?? "",
|
||||
pageTeaser: page.pageTeaser ?? "",
|
||||
pageText1: page.pageText1,
|
||||
pageText2: page.pageText2,
|
||||
pageTextDetails: page.pageTextDetails,
|
||||
pageTextTeaser: page.pageTextTeaser,
|
||||
});
|
||||
const handleDuplicate = useCallback(
|
||||
(id: number) => {
|
||||
if (state.editorDirty) {
|
||||
toast.error(t("draftBlocked"));
|
||||
return;
|
||||
}
|
||||
await loadFullTree();
|
||||
toast.success(`Duplicated "${page.caption}" (items not copied)`);
|
||||
onSelectPage(newId);
|
||||
onTreeMutation();
|
||||
} catch {
|
||||
toast.error("Failed to duplicate page");
|
||||
}
|
||||
};
|
||||
|
||||
if (!canEdit || !requestCatalogNavigation()) return;
|
||||
const node = nodes.get(id);
|
||||
if (!node) return;
|
||||
setDuplicateSource({
|
||||
kind: catalogType,
|
||||
sourceId: id,
|
||||
parentId: node.parentId,
|
||||
name: `${node.caption.slice(0, 121)} (copy)`,
|
||||
});
|
||||
},
|
||||
[state.editorDirty, t, canEdit, nodes, catalogType],
|
||||
);
|
||||
const handleOpenFull = (id: number) =>
|
||||
window.open(`${pageBase}/${id}`, "_blank");
|
||||
|
||||
@@ -780,6 +744,7 @@ export function SortableTree({
|
||||
loadingIds,
|
||||
handleToggleExpand,
|
||||
handleSelect,
|
||||
handleDuplicate,
|
||||
],
|
||||
); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -792,6 +757,16 @@ export function SortableTree({
|
||||
style={{ backgroundColor: "var(--admin-surface)" }}
|
||||
>
|
||||
{confirmDialog}
|
||||
{duplicateSource && (
|
||||
<DuplicateCategoryDialog
|
||||
source={duplicateSource}
|
||||
onClose={() => setDuplicateSource(null)}
|
||||
onComplete={() => {
|
||||
void loadFullTree();
|
||||
onTreeMutation();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="border-b px-3 py-2 space-y-2 shrink-0">
|
||||
{subpageParentId !== null ? (
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "@/components/ui/context-menu";
|
||||
@@ -251,13 +250,13 @@ export const TreeItemContent = React.memo(
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
|
||||
<ContextMenuContent className="w-52">
|
||||
<ContextMenuLabel className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
||||
<CatalogIcon iconImage={node.iconImage} size={14} />
|
||||
{node.caption}
|
||||
<span className="text-muted-foreground font-normal ml-auto">
|
||||
#{node.id}
|
||||
</span>
|
||||
</ContextMenuLabel>
|
||||
</div>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => onSelect(node.id)}>
|
||||
<Pencil className="mr-2 h-3.5 w-3.5" />
|
||||
|
||||
@@ -0,0 +1,494 @@
|
||||
"use client";
|
||||
|
||||
import { ArrowRight, Loader2, SlidersHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { applyBulkOffers, previewBulkOffers } from "@/actions/catalog-bulk";
|
||||
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
|
||||
import { POINTS_TYPES } from "@/app/admin/catalog/[id]/catalog-items-table/types";
|
||||
import {
|
||||
CurrencyIcon,
|
||||
currencyKindFromPointsType,
|
||||
} from "@/components/shared/currency-icon";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
|
||||
type BulkInput = Parameters<typeof previewBulkOffers>[0];
|
||||
type Preview = Extract<
|
||||
Awaited<ReturnType<typeof previewBulkOffers>>,
|
||||
{ ok: true }
|
||||
>["data"];
|
||||
type PriceMode = "set" | "add" | "percent";
|
||||
type PriceField = "costCredits" | "costPoints";
|
||||
type PriceDraft = { enabled: boolean; mode: PriceMode; value: string };
|
||||
const emptyPrice = (): PriceDraft => ({
|
||||
enabled: false,
|
||||
mode: "set",
|
||||
value: "0",
|
||||
});
|
||||
|
||||
export function BulkOfferEditor({
|
||||
ids,
|
||||
allPages,
|
||||
disabled = false,
|
||||
onApplied,
|
||||
beforeEdit,
|
||||
}: {
|
||||
ids: number[];
|
||||
allPages: { id: number; caption: string }[];
|
||||
disabled?: boolean;
|
||||
onApplied: () => void | Promise<void>;
|
||||
beforeEdit?: () => boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.catalog.bulk");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [prices, setPrices] = useState<Record<PriceField, PriceDraft>>({
|
||||
costCredits: emptyPrice(),
|
||||
costPoints: emptyPrice(),
|
||||
});
|
||||
const [currencyEnabled, setCurrencyEnabled] = useState(false);
|
||||
const [pointsType, setPointsType] = useState(0);
|
||||
const [destinationEnabled, setDestinationEnabled] = useState(false);
|
||||
const [pageId, setPageId] = useState<number | null>(null);
|
||||
const [preview, setPreview] = useState<{
|
||||
input: BulkInput;
|
||||
data: Preview;
|
||||
} | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const tooMany = ids.length > 500;
|
||||
const selectedFields =
|
||||
prices.costCredits.enabled ||
|
||||
prices.costPoints.enabled ||
|
||||
currencyEnabled ||
|
||||
destinationEnabled;
|
||||
|
||||
function start() {
|
||||
if (disabled || tooMany || (beforeEdit && !beforeEdit())) return;
|
||||
setPrices({ costCredits: emptyPrice(), costPoints: emptyPrice() });
|
||||
setCurrencyEnabled(false);
|
||||
setDestinationEnabled(false);
|
||||
setPageId(null);
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
}
|
||||
function updatePrice(field: PriceField, patch: Partial<PriceDraft>) {
|
||||
setPrices((previous) => ({
|
||||
...previous,
|
||||
[field]: { ...previous[field], ...patch },
|
||||
}));
|
||||
}
|
||||
async function requestPreview() {
|
||||
if (busyRef.current || disabled || tooMany || (beforeEdit && !beforeEdit()))
|
||||
return;
|
||||
const changes: BulkInput["changes"] = {};
|
||||
for (const field of ["costCredits", "costPoints"] as const) {
|
||||
const price = prices[field];
|
||||
if (!price.enabled) continue;
|
||||
const value = Number(price.value);
|
||||
if (
|
||||
!price.value.trim() ||
|
||||
!Number.isFinite(value) ||
|
||||
(price.mode !== "percent" && !Number.isInteger(value))
|
||||
) {
|
||||
setError(t("invalidPrice"));
|
||||
return;
|
||||
}
|
||||
changes[field] = { mode: price.mode, value };
|
||||
}
|
||||
if (currencyEnabled) changes.pointsType = pointsType;
|
||||
if (destinationEnabled) {
|
||||
if (pageId === null) {
|
||||
setError(t("chooseDestination"));
|
||||
return;
|
||||
}
|
||||
changes.pageId = pageId;
|
||||
}
|
||||
if (!selectedFields) {
|
||||
setError(t("chooseFields"));
|
||||
return;
|
||||
}
|
||||
const input: BulkInput = { ids: [...ids], changes };
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await previewBulkOffers(input);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
return;
|
||||
}
|
||||
setPreview({ input, data: result.data });
|
||||
} catch {
|
||||
setError(t("requestFailed"));
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function apply() {
|
||||
if (
|
||||
!preview ||
|
||||
busyRef.current ||
|
||||
disabled ||
|
||||
(beforeEdit && !beforeEdit())
|
||||
)
|
||||
return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await applyBulkOffers(
|
||||
preview.input,
|
||||
preview.data.fingerprint,
|
||||
);
|
||||
if (!result.ok) {
|
||||
setError(result.error);
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
toast.success(t("success", { count: result.data.changedCount }));
|
||||
setOpen(false);
|
||||
await onApplied();
|
||||
} catch {
|
||||
setError(t("requestFailed"));
|
||||
setPreview(null);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
const pageNames = new Map(
|
||||
(preview?.data.pages ?? allPages).map((page) => [page.id, page.caption]),
|
||||
);
|
||||
const currencyName = (type: number) =>
|
||||
POINTS_TYPES[type]
|
||||
? t(`currencies.${type}`)
|
||||
: t("unknownCurrency", { type });
|
||||
const renderChange = (before: React.ReactNode, after: React.ReactNode) => (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<span className="text-muted-foreground">{before}</span>
|
||||
<ArrowRight className="size-3 shrink-0" aria-label={t("becomes")} />
|
||||
<strong>{after}</strong>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled || tooMany || !ids.length}
|
||||
onClick={start}
|
||||
>
|
||||
<SlidersHorizontal className="mr-2 size-4" />
|
||||
{t("button", { count: ids.length })}
|
||||
</Button>
|
||||
{(disabled || tooMany) && (
|
||||
<span className="max-w-64 text-xs text-muted-foreground">
|
||||
{t(tooMany ? "selectionLimit" : "disabledReason")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(value) => {
|
||||
if (!busyRef.current) setOpen(value);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
style={{
|
||||
backgroundColor: "var(--admin-surface)",
|
||||
color: "var(--admin-text)",
|
||||
}}
|
||||
className="min-w-0 max-h-[90vh] overflow-y-auto sm:max-w-4xl [&>*]:min-w-0"
|
||||
showCloseButton={false}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t(preview ? "previewTitle" : "title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("description", { count: ids.length })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-md border border-destructive p-3 text-sm text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{!preview ? (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">{t("fieldHint")}</p>
|
||||
{(["costCredits", "costPoints"] as const).map((field) => (
|
||||
<div key={field} className="space-y-3 rounded-md border p-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={prices[field].enabled}
|
||||
onCheckedChange={(value) =>
|
||||
updatePrice(field, { enabled: value === true })
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
{t(field)}
|
||||
</Label>
|
||||
{prices[field].enabled && (
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`bulk-${field}-mode`}>
|
||||
{t("operation")}
|
||||
</Label>
|
||||
<Select
|
||||
value={prices[field].mode}
|
||||
onValueChange={(value) =>
|
||||
updatePrice(field, { mode: value as PriceMode })
|
||||
}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger id={`bulk-${field}-mode`}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(["set", "add", "percent"] as const).map(
|
||||
(mode) => (
|
||||
<SelectItem key={mode} value={mode}>
|
||||
{t(`modes.${mode}`)}
|
||||
</SelectItem>
|
||||
),
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor={`bulk-${field}-value`}>
|
||||
{t(
|
||||
prices[field].mode === "percent"
|
||||
? "percentage"
|
||||
: "value",
|
||||
)}
|
||||
</Label>
|
||||
<Input
|
||||
id={`bulk-${field}-value`}
|
||||
type="number"
|
||||
step={prices[field].mode === "percent" ? "any" : 1}
|
||||
value={prices[field].value}
|
||||
onChange={(event) =>
|
||||
updatePrice(field, { value: event.target.value })
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={currencyEnabled}
|
||||
onCheckedChange={(value) =>
|
||||
setCurrencyEnabled(value === true)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
{t("currency")}
|
||||
</Label>
|
||||
{currencyEnabled && (
|
||||
<Select
|
||||
value={String(pointsType)}
|
||||
onValueChange={(value) => setPointsType(Number(value))}
|
||||
disabled={busy}
|
||||
>
|
||||
<SelectTrigger aria-label={t("currency")}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.keys(POINTS_TYPES).map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
<span className="flex items-center gap-2">
|
||||
<CurrencyIcon
|
||||
kind={currencyKindFromPointsType(Number(type))}
|
||||
alt=""
|
||||
/>
|
||||
{currencyName(Number(type))}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<Label className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={destinationEnabled}
|
||||
onCheckedChange={(value) =>
|
||||
setDestinationEnabled(value === true)
|
||||
}
|
||||
disabled={busy}
|
||||
/>
|
||||
{t("destination")}
|
||||
</Label>
|
||||
{destinationEnabled && (
|
||||
<MovePageCombobox
|
||||
value={pageId}
|
||||
onChange={setPageId}
|
||||
pages={allPages}
|
||||
currentPageId={-1}
|
||||
disabled={busy}
|
||||
labels={{
|
||||
placeholder: t("pagePlaceholder"),
|
||||
search: t("pageSearch"),
|
||||
empty: t("pageEmpty"),
|
||||
suggested: t("pageSuggested"),
|
||||
all: t("pageAll"),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">{t("priceHint")}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("visibilityHint")}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<p className="text-sm font-medium">
|
||||
{t("previewCount", {
|
||||
count: preview.data.changedCount,
|
||||
total: preview.data.rows.length,
|
||||
})}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("beforeAfter")}
|
||||
</p>
|
||||
<div className="min-w-0 max-w-full max-h-[45vh] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{[
|
||||
"offer",
|
||||
"costCredits",
|
||||
"costPoints",
|
||||
"currency",
|
||||
"destination",
|
||||
].map((key) => (
|
||||
<TableHead key={key}>{t(key)}</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{preview.data.rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
<TableCell>
|
||||
<div className="max-w-56 whitespace-normal break-words">
|
||||
{row.catalogName}
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
#{row.id}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderChange(
|
||||
row.before.costCredits,
|
||||
row.after.costCredits,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderChange(
|
||||
row.before.costPoints,
|
||||
row.after.costPoints,
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderChange(
|
||||
currencyName(row.before.pointsType),
|
||||
currencyName(row.after.pointsType),
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{renderChange(
|
||||
`${pageNames.get(row.before.pageId) ?? ""} #${row.before.pageId}`,
|
||||
`${pageNames.get(row.after.pageId) ?? ""} #${row.after.pageId}`,
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{preview.data.changedCount === 0 && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("noChanges")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex min-w-0 flex-wrap justify-end gap-2 [&>button]:h-auto [&>button]:min-h-9 [&>button]:whitespace-normal">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setOpen(false)}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
{preview && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setPreview(null);
|
||||
setError(null);
|
||||
}}
|
||||
disabled={busy}
|
||||
>
|
||||
{t("back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={preview ? apply : requestPreview}
|
||||
disabled={
|
||||
busy ||
|
||||
disabled ||
|
||||
(!preview && !selectedFields) ||
|
||||
preview?.data.changedCount === 0
|
||||
}
|
||||
>
|
||||
{busy && <Loader2 className="mr-2 size-4 animate-spin" />}
|
||||
{preview
|
||||
? t("confirm", { count: preview.data.changedCount })
|
||||
: t("preview")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
"use client";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
|
||||
import Link from "@/components/link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { requestCatalogNavigation } from "../client/use-catalog-navigation";
|
||||
import { useLatestRequest } from "../client/use-latest-request";
|
||||
import {
|
||||
type CatalogSearchResult,
|
||||
type SearchCatalogKind,
|
||||
validSearchQuery,
|
||||
} from "../domain/search";
|
||||
import { BulkOfferEditor } from "./bulk-offer-editor";
|
||||
export function CatalogSearch({
|
||||
catalogType = "normal",
|
||||
canEdit = false,
|
||||
}: {
|
||||
catalogType?: SearchCatalogKind;
|
||||
canEdit?: boolean;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace");
|
||||
const [query, setQuery] = useState("");
|
||||
@@ -22,6 +30,69 @@ export function CatalogSearch({
|
||||
"idle",
|
||||
);
|
||||
const requests = useLatestRequest();
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const router = useRouter();
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [pages, setPages] = useState<{ id: number; caption: string }[] | null>(
|
||||
null,
|
||||
);
|
||||
const [destinationsLoading, setDestinationsLoading] = useState(false);
|
||||
const [selectionError, setSelectionError] = useState<string | null>(null);
|
||||
const destinationRequest = useRef(0);
|
||||
const destinationBusy = useRef(false);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Catalog switches invalidate the selection and destination requests.
|
||||
useEffect(() => {
|
||||
destinationRequest.current += 1;
|
||||
destinationBusy.current = false;
|
||||
setSelected(new Set());
|
||||
setPages(null);
|
||||
setDestinationsLoading(false);
|
||||
setSelectionError(null);
|
||||
return () => {
|
||||
destinationRequest.current += 1;
|
||||
};
|
||||
}, [catalogType]);
|
||||
async function loadDestinations() {
|
||||
if (destinationBusy.current) return;
|
||||
destinationBusy.current = true;
|
||||
const request = ++destinationRequest.current;
|
||||
setDestinationsLoading(true);
|
||||
setSelectionError(null);
|
||||
try {
|
||||
const result = await getBulkOfferDestinations();
|
||||
if (request !== destinationRequest.current) return;
|
||||
if (!result.ok) {
|
||||
setSelectionError(result.error);
|
||||
return;
|
||||
}
|
||||
setPages(result.data.pages);
|
||||
} catch {
|
||||
if (request === destinationRequest.current)
|
||||
setSelectionError(bulk("requestFailed"));
|
||||
} finally {
|
||||
if (request === destinationRequest.current) {
|
||||
destinationBusy.current = false;
|
||||
setDestinationsLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
function mayEdit() {
|
||||
if (requestCatalogNavigation()) return true;
|
||||
setSelectionError(bulk("disabledReason"));
|
||||
return false;
|
||||
}
|
||||
function toggleOffer(id: number) {
|
||||
if (!mayEdit()) return;
|
||||
setSelected((previous) => {
|
||||
const next = new Set(previous);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else if (next.size < 500) next.add(id);
|
||||
return next;
|
||||
});
|
||||
if (!pages) void loadDestinations();
|
||||
}
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: Successful bulk edits must refresh unchanged search queries.
|
||||
useEffect(() => {
|
||||
const request = requests.start();
|
||||
setResults([]);
|
||||
@@ -52,7 +123,7 @@ export function CatalogSearch({
|
||||
clearTimeout(timer);
|
||||
requests.cancel();
|
||||
};
|
||||
}, [query, catalogType, requests]);
|
||||
}, [query, catalogType, requests, refreshKey]);
|
||||
return (
|
||||
<section
|
||||
aria-label={t("globalSearch")}
|
||||
@@ -72,6 +143,53 @@ export function CatalogSearch({
|
||||
className="min-w-0"
|
||||
/>
|
||||
</div>
|
||||
{canEdit && catalogType === "normal" && selected.size > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-md border p-2">
|
||||
<span className="text-sm">
|
||||
{bulk("selectedCount", { count: selected.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelected(new Set())}
|
||||
>
|
||||
{bulk("clearSelection")}
|
||||
</Button>
|
||||
{pages ? (
|
||||
<BulkOfferEditor
|
||||
ids={[...selected]}
|
||||
allPages={pages}
|
||||
beforeEdit={mayEdit}
|
||||
onApplied={() => {
|
||||
setSelected(new Set());
|
||||
setRefreshKey((value) => value + 1);
|
||||
router.refresh();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={destinationsLoading}
|
||||
onClick={() => void loadDestinations()}
|
||||
>
|
||||
{destinationsLoading && (
|
||||
<Loader2 className="mr-2 size-4 animate-spin" />
|
||||
)}
|
||||
{bulk(
|
||||
destinationsLoading
|
||||
? "loadingDestinations"
|
||||
: "retryDestinations",
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{selectionError && (
|
||||
<p role="alert" className="text-sm text-[var(--admin-error)]">
|
||||
{selectionError}
|
||||
</p>
|
||||
)}
|
||||
<div aria-live="polite">
|
||||
{status === "loading" && (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
@@ -93,10 +211,26 @@ export function CatalogSearch({
|
||||
{results.length > 0 && (
|
||||
<ul className="max-h-80 overflow-y-auto divide-y divide-[var(--admin-border)]">
|
||||
{results.map((result) => (
|
||||
<li key={result.kind + result.id}>
|
||||
<li
|
||||
key={result.kind + result.id}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{canEdit &&
|
||||
catalogType === "normal" &&
|
||||
result.kind === "offer" && (
|
||||
<Checkbox
|
||||
aria-label={bulk("selectOffer", {
|
||||
name: result.caption,
|
||||
id: result.id,
|
||||
})}
|
||||
checked={selected.has(result.id)}
|
||||
disabled={selected.size >= 500 && !selected.has(result.id)}
|
||||
onCheckedChange={() => toggleOffer(result.id)}
|
||||
/>
|
||||
)}
|
||||
<Link
|
||||
href={result.href}
|
||||
className="block min-w-0 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
|
||||
className="block min-w-0 flex-1 rounded px-2 py-2 hover:bg-[var(--admin-canvas)] focus-visible:outline-2"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
applyCatalogDuplicate,
|
||||
catalogDuplicateDestinations,
|
||||
previewCatalogDuplicate,
|
||||
} from "@/actions/catalog-duplicate";
|
||||
import { MovePageCombobox } from "@/app/admin/catalog/[id]/catalog-items-table/field-helpers";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import type { DuplicateInput, DuplicatePreview } from "../domain/duplicate";
|
||||
export function DuplicateCategoryDialog({
|
||||
source,
|
||||
onClose,
|
||||
onComplete,
|
||||
}: {
|
||||
source: DuplicateInput;
|
||||
onClose: () => void;
|
||||
onComplete: (id: number) => void;
|
||||
}) {
|
||||
const t = useTranslations("pages.admin.catalog.workspace.duplicate");
|
||||
const [name, setName] = useState(source.name);
|
||||
const [parentId, setParentId] = useState<number | null>(
|
||||
source.parentId <= 0 ? -1 : source.parentId,
|
||||
);
|
||||
const nameId = useId();
|
||||
const [destinations, setDestinations] = useState<
|
||||
{ id: number; caption: string }[]
|
||||
>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let current = true;
|
||||
void catalogDuplicateDestinations(source.kind)
|
||||
.then((result) => {
|
||||
if (!current) return;
|
||||
if (result.ok) setDestinations(result.data);
|
||||
else toast.error(result.error);
|
||||
})
|
||||
.catch(() => {
|
||||
if (current) toast.error(t("failed"));
|
||||
})
|
||||
.finally(() => {
|
||||
if (current) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [source.kind, t]);
|
||||
const [preview, setPreview] = useState<DuplicatePreview | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const pending = useRef(false);
|
||||
async function run() {
|
||||
if (pending.current) return;
|
||||
pending.current = true;
|
||||
setBusy(true);
|
||||
try {
|
||||
if (!preview) {
|
||||
const result = await previewCatalogDuplicate({
|
||||
...source,
|
||||
name,
|
||||
parentId: parentId ?? -1,
|
||||
});
|
||||
if (!result.ok) throw Error(result.error);
|
||||
setPreview(result.data);
|
||||
} else {
|
||||
const result = await applyCatalogDuplicate(preview);
|
||||
if (!result.ok) {
|
||||
setPreview(null);
|
||||
throw Error(result.error);
|
||||
}
|
||||
if (result.data.hotel.sent) toast.success(t("saved"));
|
||||
else toast.warning(t("savedPending"));
|
||||
onComplete(result.data.id);
|
||||
onClose();
|
||||
}
|
||||
} catch (error) {
|
||||
setPreview(null);
|
||||
toast.error(error instanceof Error ? error.message : t("failed"));
|
||||
} finally {
|
||||
pending.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog
|
||||
open
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !pending.current) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className="max-w-lg"
|
||||
style={{
|
||||
backgroundColor: "var(--admin-surface)",
|
||||
color: "var(--admin-text)",
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<label htmlFor={nameId} className="grid gap-1 text-sm">
|
||||
{t("name")}
|
||||
<Input
|
||||
id={nameId}
|
||||
value={name}
|
||||
maxLength={128}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-1 text-sm">
|
||||
<p>{t("destination")}</p>
|
||||
<MovePageCombobox
|
||||
value={parentId}
|
||||
onChange={(id) => {
|
||||
setParentId(id);
|
||||
setPreview(null);
|
||||
}}
|
||||
pages={[{ id: -1, caption: t("root") }, ...destinations]}
|
||||
currentPageId={-2}
|
||||
disabled={busy || loading}
|
||||
labels={{
|
||||
placeholder: t("destination"),
|
||||
search: t("search"),
|
||||
empty: t("empty"),
|
||||
suggested: t("suggested"),
|
||||
all: t("all"),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t("disabled")}</p>
|
||||
{preview && (
|
||||
<div
|
||||
className="rounded-md border p-3 text-sm space-y-1"
|
||||
role="status"
|
||||
>
|
||||
<p>
|
||||
{t("counts", { pages: preview.pages, offers: preview.offers })}
|
||||
</p>
|
||||
<p>
|
||||
{t("summary", {
|
||||
name: preview.name,
|
||||
destination: preview.destination,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" disabled={busy} onClick={onClose}>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
disabled={busy || !name.trim() || parentId === null || loading}
|
||||
onClick={() => void run()}
|
||||
>
|
||||
{busy ? t("working") : preview ? t("confirm") : t("preview")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { bulkOfferInputSchema, projectBulkOffer } from "./bulk-offers";
|
||||
|
||||
const row = { pageId: 4, costCredits: 3, costPoints: 10, pointsType: 5 };
|
||||
it("combines selected changes, rounds percentages, preserves explicit zero", () => {
|
||||
expect(
|
||||
projectBulkOffer(row, {
|
||||
costCredits: { mode: "set", value: 0 },
|
||||
costPoints: { mode: "percent", value: 25 },
|
||||
pageId: 9,
|
||||
}),
|
||||
).toEqual({ ...row, costCredits: 0, costPoints: 13, pageId: 9 });
|
||||
});
|
||||
it("rejects negative results and integer overflow instead of silently clamping", () => {
|
||||
expect(() =>
|
||||
projectBulkOffer(row, { costCredits: { mode: "add", value: -4 } }),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
projectBulkOffer(row, { costCredits: { mode: "set", value: 2147483648 } }),
|
||||
).toThrow();
|
||||
});
|
||||
it("leaves legacy signed fields untouched when changing a different field", () => {
|
||||
expect(
|
||||
projectBulkOffer({ ...row, costCredits: -1 }, { pageId: 5 }).costCredits,
|
||||
).toBe(-1);
|
||||
});
|
||||
it("rejects empty, duplicate, oversized selections and unknown mutations", () => {
|
||||
for (const input of [
|
||||
{ ids: [], changes: { pageId: 1 } },
|
||||
{ ids: [1, 1], changes: { pageId: 1 } },
|
||||
{
|
||||
ids: Array.from({ length: 501 }, (_, i) => i + 1),
|
||||
changes: { pageId: 1 },
|
||||
},
|
||||
{ ids: [1], changes: {} },
|
||||
{ ids: [1], changes: { itemIds: "2" } },
|
||||
])
|
||||
expect(() => bulkOfferInputSchema.parse(input)).toThrow();
|
||||
});
|
||||
it("rejects fractional set/add prices, accepts percentage discounts", () => {
|
||||
expect(() =>
|
||||
bulkOfferInputSchema.parse({
|
||||
ids: [1],
|
||||
changes: { costCredits: { mode: "set", value: 1.5 } },
|
||||
}),
|
||||
).toThrow();
|
||||
expect(
|
||||
projectBulkOffer(row, { costPoints: { mode: "percent", value: -50 } })
|
||||
.costPoints,
|
||||
).toBe(5);
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { z } from "zod";
|
||||
import { CatalogInputError } from "./hierarchy";
|
||||
import { offerIdSchema, offerInteger } from "./offer-input";
|
||||
|
||||
const priceChange = z
|
||||
.object({
|
||||
mode: z.enum(["set", "add", "percent"]),
|
||||
value: z.number().finite().min(-2147483648).max(2147483647),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => v.mode === "percent" || Number.isInteger(v.value),
|
||||
"Set and add values must be whole numbers",
|
||||
);
|
||||
export const bulkOfferChangesSchema = z
|
||||
.object({
|
||||
costCredits: priceChange.optional(),
|
||||
costPoints: priceChange.optional(),
|
||||
pointsType: offerInteger.nonnegative().optional(),
|
||||
pageId: offerIdSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(v) => Object.values(v).some((x) => x !== undefined),
|
||||
"Choose at least one field to change",
|
||||
);
|
||||
export const bulkOfferInputSchema = z
|
||||
.object({
|
||||
ids: z
|
||||
.array(offerIdSchema)
|
||||
.min(1)
|
||||
.max(500)
|
||||
.refine(
|
||||
(v) => new Set(v).size === v.length,
|
||||
"Selection contains duplicate offers",
|
||||
),
|
||||
changes: bulkOfferChangesSchema,
|
||||
})
|
||||
.strict();
|
||||
export type BulkOfferInput = z.infer<typeof bulkOfferInputSchema>;
|
||||
export type BulkOfferValues = {
|
||||
pageId: number;
|
||||
costCredits: number;
|
||||
costPoints: number;
|
||||
pointsType: number;
|
||||
};
|
||||
export type BulkOfferPreview = {
|
||||
fingerprint: string;
|
||||
changedCount: number;
|
||||
pages: Array<{ id: number; caption: string }>;
|
||||
rows: Array<{
|
||||
id: number;
|
||||
catalogName: string;
|
||||
before: BulkOfferValues;
|
||||
after: BulkOfferValues;
|
||||
}>;
|
||||
};
|
||||
export function projectBulkOffer(
|
||||
row: BulkOfferValues,
|
||||
changes: BulkOfferInput["changes"],
|
||||
): BulkOfferValues {
|
||||
const after = { ...row };
|
||||
for (const key of ["costCredits", "costPoints"] as const) {
|
||||
const change = changes[key];
|
||||
if (!change) continue;
|
||||
const value =
|
||||
change.mode === "set"
|
||||
? change.value
|
||||
: change.mode === "add"
|
||||
? row[key] + change.value
|
||||
: Math.round(row[key] * (1 + change.value / 100));
|
||||
if (!Number.isSafeInteger(value) || value < 0 || value > 2147483647)
|
||||
throw new CatalogInputError(
|
||||
"A resulting price is negative or exceeds the supported range",
|
||||
);
|
||||
after[key] = value;
|
||||
}
|
||||
if (changes.pointsType !== undefined) after.pointsType = changes.pointsType;
|
||||
if (changes.pageId !== undefined) after.pageId = changes.pageId;
|
||||
return after;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { duplicateSubtree, remapIncludes } from "./duplicate";
|
||||
|
||||
describe("category duplication", () => {
|
||||
const pages = [
|
||||
{ id: 1, parent_id: -1 },
|
||||
{ id: 2, parent_id: 1 },
|
||||
{ id: 3, parent_id: 2 },
|
||||
{ id: 4, parent_id: -1 },
|
||||
];
|
||||
it("collects all descendants in parent-first order", () =>
|
||||
expect(duplicateSubtree(pages, 1, 4).map((p) => p.id)).toEqual([1, 2, 3]));
|
||||
it("rejects corrupt source cycles and missing destination", () => {
|
||||
expect(() =>
|
||||
duplicateSubtree(
|
||||
[
|
||||
{ id: 1, parent_id: 2 },
|
||||
{ id: 2, parent_id: 1 },
|
||||
],
|
||||
1,
|
||||
-1,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() => duplicateSubtree(pages, 1, 99)).toThrow();
|
||||
});
|
||||
it("rejects missing source ancestry", () =>
|
||||
expect(() =>
|
||||
duplicateSubtree([{ id: 1, parent_id: 99 }], 1, -1),
|
||||
).toThrow());
|
||||
it("remaps internal includes and preserves external references", () =>
|
||||
expect(remapIncludes("2;4", new Map([[2, 20]]), new Set([2, 4]))).toBe(
|
||||
"20;4",
|
||||
));
|
||||
it("rejects broken included references", () =>
|
||||
expect(() => remapIncludes("99", new Map(), new Set([2]))).toThrow());
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
assertParent,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
collectSubtree,
|
||||
positiveId,
|
||||
} from "./hierarchy";
|
||||
export type DuplicateRow = Record<string, unknown> & {
|
||||
id: number;
|
||||
parent_id?: number;
|
||||
};
|
||||
export type DuplicateInput = {
|
||||
kind: "normal" | "bc";
|
||||
sourceId: number;
|
||||
parentId: number;
|
||||
name: string;
|
||||
};
|
||||
export type DuplicatePreview = DuplicateInput & {
|
||||
fingerprint: string;
|
||||
pages: number;
|
||||
offers: number;
|
||||
destination: string;
|
||||
};
|
||||
export function duplicateSubtree<T extends DuplicateRow>(
|
||||
pages: T[],
|
||||
sourceId: number,
|
||||
parentId: number,
|
||||
): T[] {
|
||||
positiveId(sourceId);
|
||||
const source = pages.find((p) => Number(p.id) === sourceId);
|
||||
if (!source) throw new CatalogNotFound("Catalog category not found");
|
||||
const hierarchy = pages.map((p) => ({
|
||||
id: Number(p.id),
|
||||
parentId: Number(p.parent_id),
|
||||
}));
|
||||
assertParent(hierarchy, sourceId, Number(source.parent_id));
|
||||
assertParent(hierarchy, 0, parentId);
|
||||
const ids = collectSubtree(hierarchy, sourceId);
|
||||
if (ids.length > 500)
|
||||
throw new CatalogInputError("Duplicate at most 500 categories at a time");
|
||||
return ids.map((id) => pages.find((p) => Number(p.id) === id) as T);
|
||||
}
|
||||
export function remapIncludes(
|
||||
value: unknown,
|
||||
ids: Map<number, number>,
|
||||
existing: Set<number>,
|
||||
): string {
|
||||
const text = String(value ?? "");
|
||||
if (!text.trim()) return text;
|
||||
return text
|
||||
.split(";")
|
||||
.map((part) => {
|
||||
const id = Number(part.trim());
|
||||
if (!/^\d+$/.test(part.trim()) || !existing.has(id))
|
||||
throw new CatalogInputError("Included category reference not found");
|
||||
return String(ids.get(id) ?? id);
|
||||
})
|
||||
.join(";");
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
permission: vi.fn(),
|
||||
preview: vi.fn(),
|
||||
destinations: vi.fn(),
|
||||
apply: vi.fn(),
|
||||
export: vi.fn(),
|
||||
send: vi.fn(),
|
||||
audit: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { CATALOG_VIEW: "view", CATALOG_EDIT: "edit" },
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/bulk-offers", () => ({
|
||||
previewBulkOffersCommand: state.preview,
|
||||
listBulkOfferDestinationsCommand: state.destinations,
|
||||
applyBulkOffersCommand: state.apply,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: state.export,
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: state.send,
|
||||
}));
|
||||
vi.mock("@/lib/services/audit", () => ({ logAudit: state.audit }));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: state.refresh }));
|
||||
|
||||
import {
|
||||
applyBulkOffers,
|
||||
getBulkOfferDestinations,
|
||||
previewBulkOffers,
|
||||
} from "@/actions/catalog-bulk";
|
||||
|
||||
const input = { ids: [1], changes: { pageId: 2 } };
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.permission.mockResolvedValue({ id: 1 });
|
||||
state.export.mockImplementation((fn) => fn());
|
||||
state.apply.mockResolvedValue({ changedCount: 1 });
|
||||
state.preview.mockResolvedValue({ fingerprint: "x" });
|
||||
state.audit.mockResolvedValue(undefined);
|
||||
});
|
||||
it("gates read preview without scheduling any mutation", async () => {
|
||||
await previewBulkOffers(input);
|
||||
expect(state.permission).toHaveBeenCalledWith("view");
|
||||
expect(state.export).not.toHaveBeenCalled();
|
||||
expect(state.apply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("gates edits before database and sends one update after commit", async () => {
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenCalledWith("edit");
|
||||
expect(state.export).toHaveBeenCalledTimes(1);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
expect(state.apply.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
state.send.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
it("stops denied permission before export or data access", async () => {
|
||||
state.permission.mockRejectedValueOnce(Error("Denied"));
|
||||
await expect(applyBulkOffers(input, "x")).rejects.toThrow("Denied");
|
||||
expect(state.export).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not notify hotel when a transaction fails", async () => {
|
||||
state.apply.mockRejectedValueOnce(Error("failed"));
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(false);
|
||||
expect(state.send).not.toHaveBeenCalled();
|
||||
expect(state.audit).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not report a committed edit as failed on audit failure", async () => {
|
||||
state.audit.mockRejectedValueOnce(Error("log failed"));
|
||||
expect((await applyBulkOffers(input, "x")).ok).toBe(true);
|
||||
expect(state.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("loads destination choices with view permission only", async () => {
|
||||
state.destinations.mockResolvedValueOnce({
|
||||
pages: [{ id: 9, caption: "Target" }],
|
||||
});
|
||||
expect(await getBulkOfferDestinations()).toEqual({
|
||||
ok: true,
|
||||
data: { pages: [{ id: 9, caption: "Target" }] },
|
||||
});
|
||||
expect(state.permission).toHaveBeenCalledWith("view");
|
||||
expect(state.export).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,194 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
rows: [
|
||||
{
|
||||
id: 1,
|
||||
catalogName: "Chair",
|
||||
pageId: 4,
|
||||
costCredits: 3,
|
||||
costPoints: 10,
|
||||
pointsType: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
catalogName: "Table",
|
||||
pageId: 4,
|
||||
costCredits: 8,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
},
|
||||
],
|
||||
pages: [
|
||||
{ id: 4, caption: "Source" },
|
||||
{ id: 9, caption: "Target" },
|
||||
],
|
||||
queries: [] as string[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
execute: async () => [state.pages, []],
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const before = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const text = new MySqlDialect().sqlToQuery(query).sql;
|
||||
state.queries.push(text);
|
||||
if (text.startsWith("SELECT"))
|
||||
return [
|
||||
text.includes("catalog_pages") ? state.pages : state.rows,
|
||||
[],
|
||||
];
|
||||
state.writes++;
|
||||
if (state.writes === state.failAt) throw Error("write failed");
|
||||
return [{ affectedRows: 1 }, []];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = before;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
applyBulkOffersCommand,
|
||||
listBulkOfferDestinationsCommand,
|
||||
previewBulkOffersCommand,
|
||||
} from "./bulk-offers";
|
||||
|
||||
const input = {
|
||||
ids: [1, 2],
|
||||
changes: { costCredits: { mode: "add" as const, value: 1 }, pageId: 9 },
|
||||
};
|
||||
beforeEach(() => {
|
||||
state.rows = [
|
||||
{
|
||||
id: 1,
|
||||
catalogName: "Chair",
|
||||
pageId: 4,
|
||||
costCredits: 3,
|
||||
costPoints: 10,
|
||||
pointsType: 5,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
catalogName: "Table",
|
||||
pageId: 4,
|
||||
costCredits: 8,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
},
|
||||
];
|
||||
state.pages = [
|
||||
{ id: 4, caption: "Source" },
|
||||
{ id: 9, caption: "Target" },
|
||||
];
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
it("previews all changes without a write", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
expect(p.changedCount).toBe(2);
|
||||
expect(p.rows[0].after).toEqual({
|
||||
pageId: 9,
|
||||
costCredits: 4,
|
||||
costPoints: 10,
|
||||
pointsType: 5,
|
||||
});
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("locks pages before offers and commits the entire batch", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.queries = [];
|
||||
await applyBulkOffersCommand(input, p.fingerprint);
|
||||
expect(state.writes).toBe(2);
|
||||
const locks = state.queries.filter((q) => q.includes("FOR UPDATE"));
|
||||
expect(locks[0]).toContain("catalog_pages");
|
||||
expect(locks[1]).toContain("catalog_items");
|
||||
expect(
|
||||
state.queries
|
||||
.filter((q) => q.startsWith("UPDATE"))
|
||||
.every((q) => !q.includes("cost_points")),
|
||||
).toBe(true);
|
||||
});
|
||||
it("refuses stale prices and changed intent before writing", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.rows[0].costCredits = 100;
|
||||
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
|
||||
/changed/i,
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
state.rows[0].costCredits = 3;
|
||||
await expect(
|
||||
applyBulkOffersCommand({ ...input, changes: { pageId: 9 } }, p.fingerprint),
|
||||
).rejects.toThrow(/changed/i);
|
||||
});
|
||||
it("rolls back earlier updates if a later update fails", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.failAt = 2;
|
||||
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
|
||||
"write failed",
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects missing offers and destinations", async () => {
|
||||
state.rows.pop();
|
||||
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
|
||||
state.rows.push({
|
||||
id: 2,
|
||||
catalogName: "Table",
|
||||
pageId: 4,
|
||||
costCredits: 8,
|
||||
costPoints: 0,
|
||||
pointsType: 0,
|
||||
});
|
||||
state.pages.pop();
|
||||
await expect(previewBulkOffersCommand(input)).rejects.toThrow(/not found/i);
|
||||
});
|
||||
it("rejects invalid resulting price before any update", async () => {
|
||||
await expect(
|
||||
previewBulkOffersCommand({
|
||||
ids: [1, 2],
|
||||
changes: { costCredits: { mode: "add", value: -5 } },
|
||||
}),
|
||||
).rejects.toThrow(/negative/i);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rejects renamed destination after preview", async () => {
|
||||
const p = await previewBulkOffersCommand(input);
|
||||
state.pages[1].caption = "Different";
|
||||
await expect(applyBulkOffersCommand(input, p.fingerprint)).rejects.toThrow(
|
||||
/changed/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not silently truncate destination choices", async () => {
|
||||
state.pages = Array.from({ length: 5001 }, (_, i) => ({
|
||||
id: i + 1,
|
||||
caption: `Category ${i + 1}`,
|
||||
}));
|
||||
await expect(listBulkOfferDestinationsCommand()).rejects.toThrow(/5000/);
|
||||
});
|
||||
it("does not update offers whose chosen values already match", async () => {
|
||||
const request = { ids: [1, 2], changes: { pageId: 4 } };
|
||||
const p = await previewBulkOffersCommand(request);
|
||||
expect(p.changedCount).toBe(0);
|
||||
await applyBulkOffersCommand(request, p.fingerprint);
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
type BulkOfferInput,
|
||||
type BulkOfferPreview,
|
||||
type BulkOfferValues,
|
||||
bulkOfferInputSchema,
|
||||
projectBulkOffer,
|
||||
} from "../domain/bulk-offers";
|
||||
import {
|
||||
CatalogConflict,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
} from "../domain/hierarchy";
|
||||
|
||||
type Tx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
type OfferRow = BulkOfferValues & { id: number; catalogName: string };
|
||||
async function readOffers(
|
||||
tx: Tx,
|
||||
ids: number[],
|
||||
lock = false,
|
||||
): Promise<OfferRow[]> {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
|
||||
);
|
||||
const offers = (rows as unknown as OfferRow[]).map((row) => ({
|
||||
...row,
|
||||
id: Number(row.id),
|
||||
pageId: Number(row.pageId),
|
||||
costCredits: Number(row.costCredits),
|
||||
costPoints: Number(row.costPoints),
|
||||
pointsType: Number(row.pointsType),
|
||||
}));
|
||||
if (
|
||||
offers.length !== ids.length ||
|
||||
ids.some((id) => !offers.some((row) => row.id === id))
|
||||
)
|
||||
throw new CatalogNotFound(
|
||||
"Selected catalog offer not found. Reload the selection.",
|
||||
);
|
||||
return offers.sort((a, b) => a.id - b.id);
|
||||
}
|
||||
async function readPages(
|
||||
tx: Tx,
|
||||
offers: OfferRow[],
|
||||
input: BulkOfferInput,
|
||||
lock = false,
|
||||
) {
|
||||
const ids = [
|
||||
...new Set([
|
||||
...offers.map((row) => row.pageId),
|
||||
...(input.changes.pageId ? [input.changes.pageId] : []),
|
||||
]),
|
||||
].sort((a, b) => a - b);
|
||||
if (ids.some((id) => !Number.isInteger(id) || id <= 0))
|
||||
throw new CatalogInputError("An offer has an invalid category reference");
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
|
||||
);
|
||||
const pages = (rows as unknown as { id: number; caption: string }[])
|
||||
.filter((row) => ids.includes(Number(row.id)))
|
||||
.map((row) => ({ id: Number(row.id), caption: row.caption }))
|
||||
.sort((a, b) => a.id - b.id);
|
||||
if (pages.length !== ids.length)
|
||||
throw new CatalogNotFound("Source or destination category not found");
|
||||
return pages;
|
||||
}
|
||||
function preview(
|
||||
input: BulkOfferInput,
|
||||
offers: OfferRow[],
|
||||
pages: BulkOfferPreview["pages"],
|
||||
): BulkOfferPreview {
|
||||
const rows = offers.map(({ id, catalogName, ...before }) => ({
|
||||
id,
|
||||
catalogName,
|
||||
before,
|
||||
after: projectBulkOffer(before, input.changes),
|
||||
}));
|
||||
const changedCount = rows.filter((row) =>
|
||||
Object.keys(input.changes).some(
|
||||
(key) =>
|
||||
row.before[key as keyof BulkOfferValues] !==
|
||||
row.after[key as keyof BulkOfferValues],
|
||||
),
|
||||
).length;
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
input: { ...input, ids: [...input.ids].sort((a, b) => a - b) },
|
||||
rows,
|
||||
pages,
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
return { rows, pages, changedCount, fingerprint };
|
||||
}
|
||||
export async function previewBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
): Promise<BulkOfferPreview> {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
return db.transaction(async (tx) => {
|
||||
const offers = await readOffers(tx, input.ids);
|
||||
return preview(input, offers, await readPages(tx, offers, input));
|
||||
});
|
||||
}
|
||||
export async function applyBulkOffersCommand(
|
||||
value: BulkOfferInput,
|
||||
fingerprint: string,
|
||||
) {
|
||||
const input = bulkOfferInputSchema.parse(value);
|
||||
if (!/^[a-f0-9]{64}$/.test(fingerprint))
|
||||
throw new CatalogInputError("A valid preview is required");
|
||||
return db.transaction(async (tx) => {
|
||||
// Read references, then lock pages before offers to match category structural commands.
|
||||
const initial = await readOffers(tx, input.ids);
|
||||
const pages = await readPages(tx, initial, input, true);
|
||||
const offers = await readOffers(tx, input.ids, true);
|
||||
if (offers.some((row) => !pages.some((page) => page.id === row.pageId)))
|
||||
throw new CatalogConflict("The selection changed. Refresh the preview.");
|
||||
const result = preview(input, offers, pages);
|
||||
if (result.fingerprint !== fingerprint)
|
||||
throw new CatalogConflict(
|
||||
"The selection or destination changed. Refresh the preview before applying.",
|
||||
);
|
||||
const columns = {
|
||||
pageId: "page_id",
|
||||
costCredits: "cost_credits",
|
||||
costPoints: "cost_points",
|
||||
pointsType: "points_type",
|
||||
};
|
||||
for (const row of result.rows) {
|
||||
const changes = (
|
||||
Object.keys(input.changes) as Array<keyof BulkOfferValues>
|
||||
).filter((key) => row.before[key] !== row.after[key]);
|
||||
if (!changes.length) continue;
|
||||
await tx.execute(
|
||||
sql`UPDATE catalog_items SET ${sql.join(
|
||||
changes.map(
|
||||
(key) =>
|
||||
sql`${sql.identifier(columns[key])}=${key === "pageId" ? String(row.after[key]) : row.after[key]}`,
|
||||
),
|
||||
sql`, `,
|
||||
)} WHERE id=${row.id}`,
|
||||
);
|
||||
}
|
||||
return { changedCount: result.changedCount };
|
||||
});
|
||||
}
|
||||
|
||||
export async function listBulkOfferDestinationsCommand() {
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
|
||||
);
|
||||
const pages = (rows as unknown as Array<{ id: number; caption: string }>).map(
|
||||
(row) => ({ id: Number(row.id), caption: row.caption }),
|
||||
);
|
||||
if (pages.length > 5000)
|
||||
throw new CatalogInputError(
|
||||
"The catalog has more than 5000 categories. Use bulk editing from a category instead.",
|
||||
);
|
||||
return { pages };
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
failAudit: false,
|
||||
permission: vi.fn(),
|
||||
export: vi.fn(async (fn: () => unknown) => fn()),
|
||||
sync: vi.fn(async () => ({ sent: true })),
|
||||
audit: vi.fn(async () => {
|
||||
if (state.failAudit) throw Error("Audit unavailable");
|
||||
}),
|
||||
command: vi.fn(async () => ({ id: 44, pages: 2, offers: 3 })),
|
||||
}));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/admin/guard", () => ({
|
||||
requirePermission: async (permission: string) => {
|
||||
state.permission(permission);
|
||||
return { id: 7 };
|
||||
},
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/duplicate-commands", () => ({
|
||||
duplicateCategoryCommand: state.command,
|
||||
previewDuplicateCommand: vi.fn(async () => ({ pages: 2 })),
|
||||
duplicateDestinationsCommand: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/sync-status", () => ({
|
||||
sendCatalogUpdate: state.sync,
|
||||
}));
|
||||
vi.mock("@/lib/services/catalog-git-queue", () => ({
|
||||
withCatalogExport: state.export,
|
||||
}));
|
||||
vi.mock("@/lib/services/staff-activity", () => ({
|
||||
logStaffActivity: state.audit,
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
|
||||
|
||||
import {
|
||||
applyCatalogDuplicate,
|
||||
previewCatalogDuplicate,
|
||||
} from "@/actions/catalog-duplicate";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
|
||||
const input = {
|
||||
kind: "normal" as const,
|
||||
sourceId: 1,
|
||||
parentId: -1,
|
||||
name: "Copy",
|
||||
fingerprint: "a".repeat(64),
|
||||
pages: 2,
|
||||
offers: 3,
|
||||
destination: "Root",
|
||||
};
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.failAudit = false;
|
||||
});
|
||||
it("requires view for preview and edit for duplication, exports and syncs once", async () => {
|
||||
await previewCatalogDuplicate(input);
|
||||
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_VIEW);
|
||||
expect(state.export).not.toHaveBeenCalled();
|
||||
const result = await applyCatalogDuplicate(input);
|
||||
expect(result.ok).toBe(true);
|
||||
expect(state.permission).toHaveBeenLastCalledWith(PERMS.CATALOG_EDIT);
|
||||
expect(state.command).toHaveBeenCalledOnce();
|
||||
expect(state.export).toHaveBeenCalledOnce();
|
||||
expect(state.sync).toHaveBeenCalledOnce();
|
||||
expect(state.audit).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("preserves successful result after commit when audit logging fails", async () => {
|
||||
state.failAudit = true;
|
||||
await expect(applyCatalogDuplicate(input)).resolves.toMatchObject({
|
||||
ok: true,
|
||||
data: { id: 44 },
|
||||
});
|
||||
expect(state.command).toHaveBeenCalledOnce();
|
||||
});
|
||||
vi.mock("@/lib/permissions", () => ({
|
||||
PERMS: { CATALOG_EDIT: "catalog.edit", CATALOG_VIEW: "catalog.view" },
|
||||
}));
|
||||
@@ -0,0 +1,179 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pages: [
|
||||
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
|
||||
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
|
||||
],
|
||||
offers: [
|
||||
{
|
||||
id: 10,
|
||||
page_id: 2,
|
||||
item_ids: "8;8",
|
||||
cost_credits: 23,
|
||||
limited_stack: 10,
|
||||
offer_id: 10,
|
||||
},
|
||||
],
|
||||
bases: [{ id: 8 }],
|
||||
queries: [] as { sql: string; params: unknown[] }[],
|
||||
writes: 0,
|
||||
failAt: 0,
|
||||
commits: 0,
|
||||
rollbacks: 0,
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-import", () => ({
|
||||
allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const saved = state.writes;
|
||||
try {
|
||||
const result = await fn({
|
||||
execute: async (query: SQL) => {
|
||||
const compiled = new MySqlDialect().sqlToQuery(query);
|
||||
state.queries.push(compiled);
|
||||
if (compiled.sql.startsWith("SELECT"))
|
||||
return [
|
||||
compiled.sql.includes("catalog_pages")
|
||||
? state.pages
|
||||
: compiled.sql.includes("items_base")
|
||||
? state.bases
|
||||
: state.offers,
|
||||
[],
|
||||
];
|
||||
state.writes++;
|
||||
if (state.writes === state.failAt) throw Error("Insert failed");
|
||||
return [
|
||||
{
|
||||
insertId: compiled.sql.includes("`catalog_items`")
|
||||
? 0
|
||||
: 100 + state.writes,
|
||||
},
|
||||
[],
|
||||
];
|
||||
},
|
||||
});
|
||||
state.commits++;
|
||||
return result;
|
||||
} catch (error) {
|
||||
state.writes = saved;
|
||||
state.rollbacks++;
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
duplicateCategoryCommand,
|
||||
previewDuplicateCommand,
|
||||
} from "./duplicate-commands";
|
||||
|
||||
const input = {
|
||||
kind: "normal" as const,
|
||||
sourceId: 1,
|
||||
parentId: -1,
|
||||
name: "Copy",
|
||||
};
|
||||
beforeEach(() => {
|
||||
state.pages = [
|
||||
{ id: 1, parent_id: -1, caption: "Source", enabled: "1", includes: "2" },
|
||||
{ id: 2, parent_id: 1, caption: "Child", enabled: "1", includes: "" },
|
||||
];
|
||||
state.offers = [
|
||||
{
|
||||
id: 10,
|
||||
page_id: 2,
|
||||
item_ids: "8;8",
|
||||
cost_credits: 23,
|
||||
limited_stack: 10,
|
||||
offer_id: 10,
|
||||
},
|
||||
];
|
||||
state.bases = [{ id: 8 }];
|
||||
state.queries = [];
|
||||
state.writes = 0;
|
||||
state.failAt = 0;
|
||||
state.commits = 0;
|
||||
state.rollbacks = 0;
|
||||
});
|
||||
describe("atomic subtree duplication", () => {
|
||||
it("previews without writes and copies descendants, offers and internal references", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
expect(preview).toMatchObject({ pages: 2, offers: 1 });
|
||||
expect(state.writes).toBe(0);
|
||||
state.queries = [];
|
||||
await duplicateCategoryCommand(preview);
|
||||
const queries = state.queries;
|
||||
expect(queries.slice(0, 3).map((q) => q.sql)).toEqual([
|
||||
expect.stringContaining("catalog_pages"),
|
||||
expect.stringContaining("catalog_items"),
|
||||
expect.stringContaining("items_base"),
|
||||
]);
|
||||
expect(queries.slice(0, 3).every((q) => q.sql.endsWith("FOR UPDATE"))).toBe(
|
||||
true,
|
||||
);
|
||||
const inserts = queries.filter((q) => q.sql.startsWith("INSERT"));
|
||||
expect(inserts).toHaveLength(3);
|
||||
expect(inserts[0].params).toContain("0");
|
||||
expect(inserts[1].params).toContain(101);
|
||||
expect(inserts[1].params).toContain("1");
|
||||
expect(inserts[2].params.filter((p) => p === 500)).toHaveLength(2);
|
||||
expect(inserts[2].params).toEqual(
|
||||
expect.arrayContaining(["102", "8;8", 23, 10, 500]),
|
||||
);
|
||||
expect(queries.find((q) => q.sql.startsWith("UPDATE"))?.params).toEqual([
|
||||
"102",
|
||||
101,
|
||||
]);
|
||||
expect(state.pages[0].caption).toBe("Source");
|
||||
});
|
||||
it("detects stale source fields before any insert", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
state.offers[0].cost_credits = 99;
|
||||
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("rolls back pages if an offer insert fails", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
state.failAt = 4;
|
||||
await expect(duplicateCategoryCommand(preview)).rejects.toThrow(
|
||||
"Insert failed",
|
||||
);
|
||||
expect(state.writes).toBe(0);
|
||||
expect(state.rollbacks).toBe(1);
|
||||
});
|
||||
it("rejects missing furniture without writes", async () => {
|
||||
state.bases = [];
|
||||
await expect(previewDuplicateCommand(input)).rejects.toThrow("Furniture");
|
||||
expect(state.writes).toBe(0);
|
||||
});
|
||||
it("copies BC with database-generated IDs and no added currency columns", async () => {
|
||||
const preview = await previewDuplicateCommand({ ...input, kind: "bc" });
|
||||
state.queries = [];
|
||||
await duplicateCategoryCommand(preview);
|
||||
const inserts = state.queries.filter((q) => q.sql.startsWith("INSERT"));
|
||||
expect(inserts[2].sql).toContain("catalog_items_bc");
|
||||
expect(inserts[2].sql).not.toMatch(/`id`=/);
|
||||
expect(inserts[0].sql).not.toContain("caption_save");
|
||||
});
|
||||
});
|
||||
|
||||
it("invalidates the preview after the new root appears among destination siblings", async () => {
|
||||
const preview = await previewDuplicateCommand(input);
|
||||
const result = await duplicateCategoryCommand(preview);
|
||||
state.pages.push({
|
||||
id: result.id,
|
||||
parent_id: 0,
|
||||
caption: "Copy",
|
||||
enabled: "0",
|
||||
includes: "",
|
||||
});
|
||||
const writes = state.writes;
|
||||
await expect(duplicateCategoryCommand(preview)).rejects.toThrow("changed");
|
||||
expect(state.writes).toBe(writes);
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import {
|
||||
type DuplicateInput,
|
||||
type DuplicatePreview,
|
||||
type DuplicateRow,
|
||||
duplicateSubtree,
|
||||
remapIncludes,
|
||||
} from "../domain/duplicate";
|
||||
import { CatalogConflict, CatalogInputError } from "../domain/hierarchy";
|
||||
import { parseFurnitureIds } from "../domain/offer-input";
|
||||
|
||||
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
const schema = z.object({
|
||||
kind: z.enum(["normal", "bc"]),
|
||||
sourceId: z.number().int().positive(),
|
||||
parentId: z.number().int().min(-1),
|
||||
name: z.string().trim().min(1).max(128),
|
||||
});
|
||||
function table(kind: DuplicateInput["kind"], offers = false) {
|
||||
return sql.identifier(
|
||||
`catalog_${offers ? "items" : "pages"}${kind === "bc" ? "_bc" : ""}`,
|
||||
);
|
||||
}
|
||||
async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
|
||||
const suffix = lock ? sql` FOR UPDATE` : sql``;
|
||||
const [rawPages] = await tx.execute(
|
||||
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
|
||||
);
|
||||
const pages = rawPages as unknown as DuplicateRow[];
|
||||
if (pages.length > 20000)
|
||||
throw new CatalogInputError(
|
||||
"Catalog exceeds duplication safety limit (20000 categories)",
|
||||
);
|
||||
const subtree = duplicateSubtree(pages, input.sourceId, input.parentId);
|
||||
const ids = subtree.map((p) => Number(p.id));
|
||||
const existing = new Set(pages.map((p) => Number(p.id)));
|
||||
for (const page of subtree)
|
||||
if (input.kind === "normal")
|
||||
remapIncludes(page.includes, new Map(), existing);
|
||||
const [rawOffers] = await tx.execute(
|
||||
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
|
||||
);
|
||||
const offers = rawOffers as unknown as DuplicateRow[];
|
||||
if (offers.length > 5000)
|
||||
throw new CatalogInputError("Duplicate at most 5000 offers at a time");
|
||||
const furniture = [
|
||||
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
|
||||
].sort((a, b) => a - b);
|
||||
if (furniture.length) {
|
||||
const [rows] = await tx.execute(
|
||||
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
|
||||
);
|
||||
const found = new Set(
|
||||
(rows as unknown as { id: number }[]).map((p) => Number(p.id)),
|
||||
);
|
||||
if (furniture.some((id) => !found.has(id)))
|
||||
throw new CatalogInputError("Furniture reference not found");
|
||||
}
|
||||
const destination = pages.find((p) => Number(p.id) === input.parentId);
|
||||
const fingerprint = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify({
|
||||
input,
|
||||
subtree,
|
||||
offers,
|
||||
destination,
|
||||
siblings: pages.filter((page) =>
|
||||
input.parentId <= 0
|
||||
? Number(page.parent_id) <= 0
|
||||
: Number(page.parent_id) === input.parentId,
|
||||
),
|
||||
}),
|
||||
)
|
||||
.digest("hex");
|
||||
return {
|
||||
pages,
|
||||
subtree,
|
||||
offers,
|
||||
fingerprint,
|
||||
destination: String(destination?.caption ?? "Root"),
|
||||
};
|
||||
}
|
||||
export async function previewDuplicateCommand(
|
||||
raw: DuplicateInput,
|
||||
): Promise<DuplicatePreview> {
|
||||
const input = schema.parse(raw);
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const data = await snapshot(tx, input, false);
|
||||
return {
|
||||
...input,
|
||||
fingerprint: data.fingerprint,
|
||||
pages: data.subtree.length,
|
||||
offers: data.offers.length,
|
||||
destination: data.destination,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
export async function duplicateCategoryCommand(
|
||||
raw: DuplicatePreview,
|
||||
): Promise<{ id: number; pages: number; offers: number }> {
|
||||
const input = schema.parse(raw);
|
||||
const expected = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/)
|
||||
.parse(raw.fingerprint);
|
||||
const count = z.number().int().min(0).max(5000).parse(raw.offers);
|
||||
// Reserve through the same allocator as regular imports before taking page locks.
|
||||
// A database uniqueness conflict rolls back every copied row, including pages.
|
||||
const reserved: number[] = [];
|
||||
if (input.kind === "normal")
|
||||
for (let i = 0; i < count; i++)
|
||||
reserved.push(await allocateCatalogItemId(async (id) => id));
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const data = await snapshot(tx, input, true);
|
||||
if (data.fingerprint !== expected || data.offers.length !== count)
|
||||
throw new CatalogConflict(
|
||||
"Source or destination changed. Refresh the duplication preview.",
|
||||
);
|
||||
const ids = new Map<number, number>();
|
||||
const insert = async (row: Record<string, unknown>, offers = false) => {
|
||||
const entries = Object.entries(row).filter(([, v]) => v !== undefined);
|
||||
const [result] = await tx.execute(
|
||||
sql`INSERT INTO ${table(input.kind, offers)} SET ${sql.join(
|
||||
entries.map(([k, v]) => sql`${sql.identifier(k)}=${v}`),
|
||||
sql`, `,
|
||||
)}`,
|
||||
);
|
||||
const id = Number(
|
||||
row.id ?? (result as unknown as { insertId: number }).insertId,
|
||||
);
|
||||
if (!id) throw Error("Could not allocate duplicate category ID");
|
||||
return id;
|
||||
};
|
||||
for (const source of data.subtree) {
|
||||
const { id: oldId, ...row } = source;
|
||||
const isRoot = Number(oldId) === input.sourceId;
|
||||
row.parent_id = isRoot
|
||||
? input.parentId
|
||||
: ids.get(Number(source.parent_id));
|
||||
if (isRoot) {
|
||||
row.caption = input.name;
|
||||
row.enabled = "0";
|
||||
row.visible = "0";
|
||||
if (input.kind === "normal")
|
||||
row.caption_save = input.name.slice(0, 25);
|
||||
}
|
||||
if (input.kind === "normal") row.includes = "";
|
||||
ids.set(Number(oldId), await insert(row));
|
||||
}
|
||||
const existing = new Set(data.pages.map((p) => Number(p.id)));
|
||||
for (const source of data.subtree)
|
||||
if (input.kind === "normal" && source.includes) {
|
||||
const includes = remapIncludes(source.includes, ids, existing);
|
||||
if (includes.length > 128)
|
||||
throw new CatalogInputError(
|
||||
"Remapped category references exceed 128 characters",
|
||||
);
|
||||
await tx.execute(
|
||||
sql`UPDATE ${table(input.kind)} SET includes=${includes} WHERE id=${ids.get(Number(source.id))}`,
|
||||
);
|
||||
}
|
||||
const offerIds = new Map(
|
||||
data.offers.map((offer, index) => [Number(offer.id), reserved[index]]),
|
||||
);
|
||||
for (const [index, source] of data.offers.entries()) {
|
||||
const { id: _id, ...row } = source;
|
||||
row.page_id = String(ids.get(Number(source.page_id)));
|
||||
if (input.kind === "normal") {
|
||||
row.id = reserved[index];
|
||||
if (Number(row.offer_id) > 0 && offerIds.has(Number(row.offer_id)))
|
||||
row.offer_id = offerIds.get(Number(row.offer_id));
|
||||
}
|
||||
await insert(row, true);
|
||||
}
|
||||
return {
|
||||
id: ids.get(input.sourceId) as number,
|
||||
pages: data.subtree.length,
|
||||
offers: data.offers.length,
|
||||
};
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
export async function duplicateDestinationsCommand(
|
||||
kind: DuplicateInput["kind"],
|
||||
) {
|
||||
z.enum(["normal", "bc"]).parse(kind);
|
||||
const [rows] = await db.execute(
|
||||
sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`,
|
||||
);
|
||||
const pages = rows as unknown as { id: number; caption: string }[];
|
||||
if (pages.length > 20000)
|
||||
throw new CatalogInputError(
|
||||
"Catalog exceeds duplication safety limit (20000 categories)",
|
||||
);
|
||||
return pages.map((row) => ({ id: Number(row.id), caption: row.caption }));
|
||||
}
|
||||
+80
-1
@@ -1944,7 +1944,86 @@
|
||||
"searchFailed": "Search failed. Try again.",
|
||||
"searching": "Searching...",
|
||||
"category": "Category",
|
||||
"offer": "Offer"
|
||||
"offer": "Offer",
|
||||
"duplicate": {
|
||||
"title": "Duplicate category",
|
||||
"description": "Copy this category, every descendant and all their offers.",
|
||||
"name": "New category name",
|
||||
"destination": "Destination category",
|
||||
"disabled": "The copied root starts disabled and hidden. Descendants keep their settings; furniture definitions and assets are shared.",
|
||||
"counts": "{pages} categories and {offers} offers",
|
||||
"summary": "“{name}” under {destination}",
|
||||
"cancel": "Cancel",
|
||||
"working": "Working…",
|
||||
"confirm": "Confirm duplication",
|
||||
"preview": "Preview copy",
|
||||
"saved": "Category and offers duplicated",
|
||||
"savedPending": "Copy saved; hotel update pending",
|
||||
"failed": "Duplication failed",
|
||||
"root": "Catalog root",
|
||||
"search": "Search categories…",
|
||||
"empty": "No category found",
|
||||
"suggested": "Suggested",
|
||||
"all": "All categories",
|
||||
"draftBlocked": "Save or discard category edits before duplicating."
|
||||
}
|
||||
},
|
||||
"bulk": {
|
||||
"previewCount": "{count} of {total} offers will change.",
|
||||
"success": "Updated {count} offers.",
|
||||
"selectOffer": "Select offer {name} (#{id})",
|
||||
"pageEmpty": "No category found.",
|
||||
"clearSelection": "Clear selection",
|
||||
"disabledReason": "Save or reset pending edits and wait for current operations before bulk editing.",
|
||||
"operation": "Operation",
|
||||
"loadingDestinations": "Loading categories…",
|
||||
"cancel": "Cancel",
|
||||
"pagePlaceholder": "Select destination category…",
|
||||
"noChanges": "The selected values already match. No changes to save.",
|
||||
"costPoints": "Points price",
|
||||
"chooseFields": "Check at least one field to change.",
|
||||
"pageAll": "All categories",
|
||||
"offer": "Offer",
|
||||
"costCredits": "Credits",
|
||||
"invalidPrice": "Enter a valid number. Set and add operations require whole numbers.",
|
||||
"modes": {
|
||||
"set": "Set to value",
|
||||
"add": "Add or subtract",
|
||||
"percent": "Change by percentage"
|
||||
},
|
||||
"fieldHint": "Only checked fields will change.",
|
||||
"currencies": {
|
||||
"0": "Duckets",
|
||||
"5": "Diamonds",
|
||||
"101": "Snowflakes",
|
||||
"103": "Hearts",
|
||||
"104": "Stars",
|
||||
"105": "Shells"
|
||||
},
|
||||
"currency": "Points currency",
|
||||
"button": "Bulk edit ({count})",
|
||||
"description": "{count} selected offers. Preview changes before saving.",
|
||||
"confirm": "Confirm and save {count} offers",
|
||||
"beforeAfter": "Each cell shows the current value → the new value.",
|
||||
"requestFailed": "The request failed. Refresh the preview before trying again.",
|
||||
"priceHint": "Use negative additions or percentages to reduce prices. Percentage results round to the nearest integer; final prices cannot be negative.",
|
||||
"selectedCount": "{count} offers selected across categories",
|
||||
"value": "Value",
|
||||
"unknownCurrency": "Type {type}",
|
||||
"becomes": "becomes",
|
||||
"title": "Edit selected offers",
|
||||
"preview": "Preview changes",
|
||||
"retryDestinations": "Retry loading categories",
|
||||
"chooseDestination": "Select a destination category.",
|
||||
"pageSuggested": "Suggested",
|
||||
"previewTitle": "Review bulk changes",
|
||||
"visibilityHint": "Visibility is controlled by the category. Moving offers makes them follow the destination category's visibility.",
|
||||
"percentage": "Percentage (%)",
|
||||
"destination": "Destination category",
|
||||
"pageSearch": "Search categories…",
|
||||
"back": "Edit changes",
|
||||
"selectionLimit": "Select at most 500 offers for one operation.",
|
||||
"refreshing": "Refreshing updated offers…"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
+80
-1
@@ -1863,7 +1863,86 @@
|
||||
"searchFailed": "Ricerca non riuscita. Riprova.",
|
||||
"searching": "Ricerca in corso...",
|
||||
"category": "Categoria",
|
||||
"offer": "Offerta"
|
||||
"offer": "Offerta",
|
||||
"duplicate": {
|
||||
"title": "Duplica categoria",
|
||||
"description": "Copia questa categoria, tutte le sottocategorie e le relative offerte.",
|
||||
"name": "Nome della nuova categoria",
|
||||
"destination": "Categoria di destinazione",
|
||||
"disabled": "La radice copiata parte disabilitata e nascosta. Le sottocategorie mantengono le impostazioni; definizioni degli arredi e asset sono condivisi.",
|
||||
"counts": "{pages} categorie e {offers} offerte",
|
||||
"summary": "“{name}” sotto {destination}",
|
||||
"cancel": "Annulla",
|
||||
"working": "Operazione in corso…",
|
||||
"confirm": "Conferma duplicazione",
|
||||
"preview": "Anteprima copia",
|
||||
"saved": "Categoria e offerte duplicate",
|
||||
"savedPending": "Copia salvata; aggiornamento hotel in attesa",
|
||||
"failed": "Duplicazione non riuscita",
|
||||
"root": "Radice del catalogo",
|
||||
"search": "Cerca categorie…",
|
||||
"empty": "Nessuna categoria trovata",
|
||||
"suggested": "Suggerite",
|
||||
"all": "Tutte le categorie",
|
||||
"draftBlocked": "Salva o annulla le modifiche alla categoria prima di duplicarla."
|
||||
}
|
||||
},
|
||||
"bulk": {
|
||||
"previewCount": "Verranno modificate {count} offerte su {total}.",
|
||||
"success": "{count} offerte aggiornate.",
|
||||
"selectOffer": "Seleziona offerta {name} (#{id})",
|
||||
"pageEmpty": "Nessuna categoria trovata.",
|
||||
"clearSelection": "Svuota selezione",
|
||||
"disabledReason": "Salva o annulla le modifiche in sospeso e attendi la fine delle operazioni prima di modificare in blocco.",
|
||||
"operation": "Operazione",
|
||||
"loadingDestinations": "Caricamento categorie…",
|
||||
"cancel": "Annulla",
|
||||
"pagePlaceholder": "Seleziona la categoria di destinazione…",
|
||||
"noChanges": "I valori selezionati corrispondono già. Nessuna modifica da salvare.",
|
||||
"costPoints": "Prezzo in punti",
|
||||
"chooseFields": "Seleziona almeno un campo da modificare.",
|
||||
"pageAll": "Tutte le categorie",
|
||||
"offer": "Offerta",
|
||||
"costCredits": "Crediti",
|
||||
"invalidPrice": "Inserisci un numero valido. Le operazioni di impostazione e aggiunta richiedono numeri interi.",
|
||||
"modes": {
|
||||
"set": "Imposta valore",
|
||||
"add": "Aggiungi o sottrai",
|
||||
"percent": "Varia in percentuale"
|
||||
},
|
||||
"fieldHint": "Verranno modificati solo i campi selezionati.",
|
||||
"currencies": {
|
||||
"0": "Duckets",
|
||||
"5": "Diamanti",
|
||||
"101": "Fiocchi di neve",
|
||||
"103": "Cuori",
|
||||
"104": "Stelle",
|
||||
"105": "Conchiglie"
|
||||
},
|
||||
"currency": "Valuta dei punti",
|
||||
"button": "Modifica in blocco ({count})",
|
||||
"description": "{count} offerte selezionate. Controlla l'anteprima prima di salvare.",
|
||||
"confirm": "Conferma e salva {count} offerte",
|
||||
"beforeAfter": "Ogni cella mostra il valore attuale → il nuovo valore.",
|
||||
"requestFailed": "Richiesta non riuscita. Aggiorna l'anteprima prima di riprovare.",
|
||||
"priceHint": "Usa valori o percentuali negativi per ridurre i prezzi. Le percentuali vengono arrotondate all'intero più vicino; i prezzi finali non possono essere negativi.",
|
||||
"selectedCount": "{count} offerte selezionate tra le categorie",
|
||||
"value": "Valore",
|
||||
"unknownCurrency": "Tipo {type}",
|
||||
"becomes": "diventa",
|
||||
"title": "Modifica le offerte selezionate",
|
||||
"preview": "Anteprima modifiche",
|
||||
"retryDestinations": "Riprova a caricare le categorie",
|
||||
"chooseDestination": "Seleziona una categoria di destinazione.",
|
||||
"pageSuggested": "Suggerite",
|
||||
"previewTitle": "Controlla le modifiche in blocco",
|
||||
"visibilityHint": "La visibilità dipende dalla categoria. Le offerte spostate seguono la visibilità della categoria di destinazione.",
|
||||
"percentage": "Percentuale (%)",
|
||||
"destination": "Categoria di destinazione",
|
||||
"pageSearch": "Cerca categorie…",
|
||||
"back": "Rivedi le modifiche",
|
||||
"selectionLimit": "Seleziona al massimo 500 offerte per operazione.",
|
||||
"refreshing": "Aggiornamento delle offerte modificate…"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
+80
-1
@@ -1693,7 +1693,86 @@
|
||||
"searchFailed": "Zoeken mislukt. Probeer opnieuw.",
|
||||
"searching": "Zoeken...",
|
||||
"category": "Categorie",
|
||||
"offer": "Aanbieding"
|
||||
"offer": "Aanbieding",
|
||||
"duplicate": {
|
||||
"title": "Categorie dupliceren",
|
||||
"description": "Kopieer deze categorie, alle subcategorieën en alle bijbehorende aanbiedingen.",
|
||||
"name": "Naam van de nieuwe categorie",
|
||||
"destination": "Doelcategorie",
|
||||
"disabled": "De gekopieerde hoofdcategorie begint uitgeschakeld en verborgen. Subcategorieën behouden hun instellingen; meubeldefinities en bestanden worden gedeeld.",
|
||||
"counts": "{pages} categorieën en {offers} aanbiedingen",
|
||||
"summary": "‘{name}’ onder {destination}",
|
||||
"cancel": "Annuleren",
|
||||
"working": "Bezig…",
|
||||
"confirm": "Dupliceren bevestigen",
|
||||
"preview": "Kopie bekijken",
|
||||
"saved": "Categorie en aanbiedingen gedupliceerd",
|
||||
"savedPending": "Kopie opgeslagen; hotelupdate in afwachting",
|
||||
"failed": "Dupliceren mislukt",
|
||||
"root": "Catalogushoofdmap",
|
||||
"search": "Categorieën zoeken…",
|
||||
"empty": "Geen categorie gevonden",
|
||||
"suggested": "Voorgesteld",
|
||||
"all": "Alle categorieën",
|
||||
"draftBlocked": "Sla categoriewijzigingen op of annuleer ze voordat je dupliceert."
|
||||
}
|
||||
},
|
||||
"bulk": {
|
||||
"previewCount": "{count} van {total} aanbiedingen worden gewijzigd.",
|
||||
"success": "{count} aanbiedingen bijgewerkt.",
|
||||
"selectOffer": "Selecteer aanbieding {name} (#{id})",
|
||||
"pageEmpty": "Geen categorie gevonden.",
|
||||
"clearSelection": "Selectie wissen",
|
||||
"disabledReason": "Sla openstaande wijzigingen op of herstel ze en wacht tot lopende bewerkingen klaar zijn voordat je in bulk bewerkt.",
|
||||
"operation": "Bewerking",
|
||||
"loadingDestinations": "Categorieën laden…",
|
||||
"cancel": "Annuleren",
|
||||
"pagePlaceholder": "Selecteer de doelcategorie…",
|
||||
"noChanges": "De geselecteerde waarden komen al overeen. Geen wijzigingen om op te slaan.",
|
||||
"costPoints": "Prijs in punten",
|
||||
"chooseFields": "Vink minstens één veld aan om te wijzigen.",
|
||||
"pageAll": "Alle categorieën",
|
||||
"offer": "Aanbieding",
|
||||
"costCredits": "Credits",
|
||||
"invalidPrice": "Voer een geldig getal in. Instellen en optellen vereisen gehele getallen.",
|
||||
"modes": {
|
||||
"set": "Waarde instellen",
|
||||
"add": "Optellen of aftrekken",
|
||||
"percent": "Procentueel wijzigen"
|
||||
},
|
||||
"fieldHint": "Alleen aangevinkte velden worden gewijzigd.",
|
||||
"currencies": {
|
||||
"0": "Duckets",
|
||||
"5": "Diamanten",
|
||||
"101": "Sneeuwvlokken",
|
||||
"103": "Harten",
|
||||
"104": "Sterren",
|
||||
"105": "Schelpen"
|
||||
},
|
||||
"currency": "Puntenvaluta",
|
||||
"button": "Bulk bewerken ({count})",
|
||||
"description": "{count} aanbiedingen geselecteerd. Bekijk de wijzigingen voordat je opslaat.",
|
||||
"confirm": "Bevestig en sla {count} aanbiedingen op",
|
||||
"beforeAfter": "Elke cel toont de huidige waarde → de nieuwe waarde.",
|
||||
"requestFailed": "Het verzoek is mislukt. Vernieuw het voorbeeld voordat je het opnieuw probeert.",
|
||||
"priceHint": "Gebruik negatieve waarden of percentages om prijzen te verlagen. Procentuele resultaten worden afgerond op het dichtstbijzijnde gehele getal; eindprijzen mogen niet negatief zijn.",
|
||||
"selectedCount": "{count} aanbiedingen geselecteerd in categorieën",
|
||||
"value": "Waarde",
|
||||
"unknownCurrency": "Type {type}",
|
||||
"becomes": "wordt",
|
||||
"title": "Geselecteerde aanbiedingen bewerken",
|
||||
"preview": "Wijzigingen bekijken",
|
||||
"retryDestinations": "Categorieën opnieuw laden",
|
||||
"chooseDestination": "Selecteer een doelcategorie.",
|
||||
"pageSuggested": "Voorgesteld",
|
||||
"previewTitle": "Controleer de wijzigingen",
|
||||
"visibilityHint": "De categorie bepaalt de zichtbaarheid. Verplaatste aanbiedingen volgen de zichtbaarheid van de doelcategorie.",
|
||||
"percentage": "Percentage (%)",
|
||||
"destination": "Doelcategorie",
|
||||
"pageSearch": "Categorieën zoeken…",
|
||||
"back": "Wijzigingen aanpassen",
|
||||
"selectionLimit": "Selecteer maximaal 500 aanbiedingen per bewerking.",
|
||||
"refreshing": "Bijgewerkte aanbiedingen vernieuwen…"
|
||||
}
|
||||
},
|
||||
"shop": {
|
||||
|
||||
Reference in new issue
Block a user