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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user