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,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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user