feat(catalog): add saved packages, preview and bulk price editing
This commit is contained in:
@@ -0,0 +1,632 @@
|
||||
"use client";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useRef, useState } from "react";
|
||||
import { getBulkOfferDestinations } from "@/actions/catalog-bulk";
|
||||
import {
|
||||
createCatalogPackage,
|
||||
getCatalogPackage,
|
||||
listCatalogPackages,
|
||||
previewCatalogPackage,
|
||||
publishCatalogPackage,
|
||||
saveCatalogPackage,
|
||||
} from "@/actions/catalog-packages";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
type CatalogPackage,
|
||||
type PackageListRow,
|
||||
type PackagePreview,
|
||||
type PackagePublication,
|
||||
type PackageSnapshot,
|
||||
savePackageSchema,
|
||||
} from "@/features/catalog/domain/packages";
|
||||
import { useUnsavedChanges } from "@/hooks/use-unsaved-changes";
|
||||
import { CatalogPackagePreview } from "./catalog-package-preview";
|
||||
import { PackageCategoryPicker } from "./package-category-picker";
|
||||
import {
|
||||
packageChanges,
|
||||
packagePublicationIsCurrent,
|
||||
} from "./package-editor-helpers";
|
||||
import { PackageSnapshotEditor } from "./package-snapshot-editor";
|
||||
|
||||
export function CatalogPackagesDialog({ canEdit }: { canEdit: boolean }) {
|
||||
const t = useTranslations("pages.admin.catalog.packages");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [error, setError] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [packages, setPackages] = useState<PackageListRow[]>([]);
|
||||
const [categories, setCategories] = useState<
|
||||
Array<{ id: number; caption: string }>
|
||||
>([]);
|
||||
const [listQuery, setListQuery] = useState("");
|
||||
const [listPage, setListPage] = useState(0);
|
||||
const [saved, setSaved] = useState<CatalogPackage | null>(null);
|
||||
const [draft, setDraft] = useState<PackageSnapshot | null>(null);
|
||||
const [name, setName] = useState("");
|
||||
const [target, setTarget] = useState(-1);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [mode, setMode] = useState<"update" | "copy">("update");
|
||||
const [pageIds, setPageIds] = useState<number[]>([]);
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [tab, setTab] = useState<"edit" | "preview" | "review">("edit");
|
||||
const [previewBaseline, setPreviewBaseline] = useState(false);
|
||||
const [review, setReview] = useState<PackagePreview | null>(null);
|
||||
const [reviewPage, setReviewPage] = useState(0);
|
||||
const [publication, setPublication] = useState<PackagePublication | null>(
|
||||
null,
|
||||
);
|
||||
const dirty = creating
|
||||
? Boolean(name || pageIds.length || mode !== "update" || target !== -1)
|
||||
: Boolean(
|
||||
saved &&
|
||||
draft &&
|
||||
(saved.name !== name ||
|
||||
saved.targetParentId !== target ||
|
||||
JSON.stringify(saved.draft) !== JSON.stringify(draft)),
|
||||
);
|
||||
useUnsavedChanges(open && dirty, t("discardChanges"));
|
||||
const editable = canEdit && saved?.status === "draft" && !publication;
|
||||
const matches = packages.filter((row) =>
|
||||
`${row.name} ${row.id}`.toLowerCase().includes(listQuery.toLowerCase()),
|
||||
);
|
||||
const changes = saved && draft ? packageChanges(saved.baseline, draft) : [];
|
||||
function invalidate() {
|
||||
setReview(null);
|
||||
setPublication(null);
|
||||
setNotice("");
|
||||
setRevision((value) => value + 1);
|
||||
}
|
||||
function mayLeave() {
|
||||
return !busyRef.current && (!dirty || window.confirm(t("discardChanges")));
|
||||
}
|
||||
async function run(operation: () => Promise<void>) {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
await operation();
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : t("requestFailed"));
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
function selectPackage(value: CatalogPackage) {
|
||||
setSaved(value);
|
||||
setDraft(value.draft);
|
||||
setName(value.name);
|
||||
setTarget(value.targetParentId);
|
||||
setCreating(false);
|
||||
setReview(null);
|
||||
setPublication(null);
|
||||
setTab("edit");
|
||||
setRevision((value) => value + 1);
|
||||
setNotice("");
|
||||
}
|
||||
async function refreshList() {
|
||||
const result = await listCatalogPackages();
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
setPackages(result.data);
|
||||
}
|
||||
async function start() {
|
||||
setOpen(true);
|
||||
await run(async () => {
|
||||
const results = await Promise.allSettled([
|
||||
refreshList(),
|
||||
getBulkOfferDestinations(),
|
||||
]);
|
||||
if (results[0].status === "rejected") throw results[0].reason;
|
||||
const destinations = results[1];
|
||||
if (destinations.status === "rejected") throw destinations.reason;
|
||||
if (!destinations.value.ok) throw new Error(destinations.value.error);
|
||||
setCategories(destinations.value.data.pages);
|
||||
});
|
||||
}
|
||||
function newPackage() {
|
||||
if (!mayLeave()) return;
|
||||
setSaved(null);
|
||||
setDraft(null);
|
||||
setName("");
|
||||
setTarget(-1);
|
||||
setMode("update");
|
||||
setPageIds([]);
|
||||
setCreating(true);
|
||||
setReview(null);
|
||||
setPublication(null);
|
||||
setNotice("");
|
||||
setError("");
|
||||
}
|
||||
async function openPackage(id: string) {
|
||||
if (!mayLeave()) return;
|
||||
await run(async () => {
|
||||
const result = await getCatalogPackage(id);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
selectPackage(result.data);
|
||||
});
|
||||
}
|
||||
async function save() {
|
||||
if (!saved || !draft || !editable) return;
|
||||
const input = savePackageSchema.safeParse({
|
||||
id: saved.id,
|
||||
version: saved.version,
|
||||
name,
|
||||
draft,
|
||||
targetParentId: target,
|
||||
});
|
||||
if (!input.success) {
|
||||
setError(t("invalidFields"));
|
||||
return;
|
||||
}
|
||||
await run(async () => {
|
||||
const result = await saveCatalogPackage(input.data);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
selectPackage(result.data);
|
||||
setNotice(t("draftSaved"));
|
||||
await refreshList();
|
||||
});
|
||||
}
|
||||
async function requestReview() {
|
||||
if (!saved || dirty || !editable) return;
|
||||
setReview(null);
|
||||
await run(async () => {
|
||||
const result = await previewCatalogPackage(saved.id);
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
if (result.data.version !== saved.version) {
|
||||
setReview(null);
|
||||
throw new Error(t("staleReview"));
|
||||
}
|
||||
setReview(result.data);
|
||||
setReviewPage(0);
|
||||
setTab("review");
|
||||
});
|
||||
}
|
||||
async function publish() {
|
||||
if (
|
||||
!saved ||
|
||||
!review ||
|
||||
dirty ||
|
||||
!packagePublicationIsCurrent(saved, review, dirty) ||
|
||||
!editable ||
|
||||
!window.confirm(t("confirmPublish"))
|
||||
)
|
||||
return;
|
||||
await run(async () => {
|
||||
const result = await publishCatalogPackage({
|
||||
id: saved.id,
|
||||
version: saved.version,
|
||||
fingerprint: review.fingerprint,
|
||||
});
|
||||
if (!result.ok) {
|
||||
setReview(null);
|
||||
throw new Error(result.error);
|
||||
}
|
||||
setPublication(result.data);
|
||||
setReview(null);
|
||||
setNotice(
|
||||
t("publishedSuccess", {
|
||||
pages: result.data.pageIds.length,
|
||||
offers: result.data.offerCount,
|
||||
}),
|
||||
);
|
||||
const current = await getCatalogPackage(saved.id).catch(() => null);
|
||||
if (current?.ok) {
|
||||
setSaved(current.data);
|
||||
setDraft(current.data.draft);
|
||||
}
|
||||
await refreshList();
|
||||
});
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" onClick={() => void start()}>
|
||||
{t("openPackages")}
|
||||
</Button>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (next || mayLeave()) {
|
||||
setOpen(next);
|
||||
if (!next) {
|
||||
setSaved(null);
|
||||
setDraft(null);
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="flex h-[90dvh] w-[calc(100vw-2rem)] max-w-7xl flex-col overflow-hidden border-[var(--admin-border)] bg-[var(--admin-surface)] text-[var(--admin-text)] sm:max-w-7xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("title")}</DialogTitle>
|
||||
<DialogDescription>{t("description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
className="shrink-0 break-words text-sm text-[var(--admin-error)]"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p role="status" className="shrink-0 text-sm">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
{busy && (
|
||||
<p role="status" className="shrink-0 text-sm">
|
||||
{t("working")}
|
||||
</p>
|
||||
)}
|
||||
<fieldset
|
||||
disabled={busy}
|
||||
className="grid min-h-0 min-w-0 w-full flex-1 gap-4 overflow-y-auto lg:grid-cols-[16rem_minmax(0,1fr)]"
|
||||
>
|
||||
<aside className="space-y-3 lg:overflow-y-auto">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canEdit && (
|
||||
<Button onClick={newPackage}>{t("createPackage")}</Button>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void run(refreshList)}>
|
||||
{t("refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="block text-sm">
|
||||
{t("searchPackages")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
value={listQuery}
|
||||
onChange={(event) => {
|
||||
setListQuery(event.target.value);
|
||||
setListPage(0);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<ul className="space-y-2">
|
||||
{matches
|
||||
.slice(listPage * 20, (listPage + 1) * 20)
|
||||
.map((row) => (
|
||||
<li key={row.id}>
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={saved?.id === row.id}
|
||||
className={`w-full rounded border p-3 text-left ${saved?.id === row.id ? "border-[var(--admin-accent)]" : "border-[var(--admin-border)]"}`}
|
||||
onClick={() => void openPackage(row.id)}
|
||||
>
|
||||
<strong className="block break-words text-sm">
|
||||
{row.name}
|
||||
</strong>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t(row.status)} · {t(row.mode)} · v{row.version}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{!matches.length && <p className="text-sm">{t("noPackages")}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!listPage}
|
||||
onClick={() => setListPage(listPage - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(listPage + 1) * 20 >= matches.length}
|
||||
onClick={() => setListPage(listPage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="min-w-0 space-y-4 lg:overflow-y-auto lg:pr-2">
|
||||
{creating && (
|
||||
<section className="space-y-4">
|
||||
<h3 className="font-semibold">{t("createPackage")}</h3>
|
||||
<label className="block text-sm">
|
||||
{t("name")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
maxLength={128}
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label className="block text-sm">
|
||||
{t("mode")}
|
||||
<select
|
||||
className="input mt-1 w-full"
|
||||
value={mode}
|
||||
onChange={(event) =>
|
||||
setMode(event.target.value as typeof mode)
|
||||
}
|
||||
>
|
||||
<option value="update">{t("update")}</option>
|
||||
<option value="copy">{t("copy")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-sm">
|
||||
{t(mode === "copy" ? "copyHint" : "updateHint")}
|
||||
</p>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("sourceCategories")}
|
||||
</h4>
|
||||
<p className="text-xs text-[var(--admin-text-muted)]">
|
||||
{t("packageLimits")}
|
||||
</p>
|
||||
<PackageCategoryPicker
|
||||
pages={categories}
|
||||
selected={pageIds}
|
||||
onChange={setPageIds}
|
||||
multiple
|
||||
/>
|
||||
{mode === "copy" && (
|
||||
<div className="space-y-2">
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("targetParent")}
|
||||
</h4>
|
||||
<PackageCategoryPicker
|
||||
pages={categories}
|
||||
selected={[target]}
|
||||
onChange={(ids) => setTarget(ids[0])}
|
||||
root
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
disabled={!name.trim() || !pageIds.length}
|
||||
onClick={() =>
|
||||
void run(async () => {
|
||||
const result = await createCatalogPackage({
|
||||
name,
|
||||
pageIds,
|
||||
mode,
|
||||
targetParentId: mode === "copy" ? target : -1,
|
||||
});
|
||||
if (!result.ok) throw new Error(result.error);
|
||||
selectPackage(result.data);
|
||||
setNotice(t("draftCreated"));
|
||||
await refreshList();
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("createDraft")}
|
||||
</Button>
|
||||
</section>
|
||||
)}
|
||||
{!creating && !saved && (
|
||||
<p className="text-sm">{t("choosePackage")}</p>
|
||||
)}
|
||||
{saved && draft && (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-3 text-sm">
|
||||
<strong>
|
||||
{t(saved.status)} · v{saved.version}
|
||||
</strong>
|
||||
<span>{t(saved.mode)}</span>
|
||||
{dirty && <span role="status">{t("unsaved")}</span>}
|
||||
</div>
|
||||
<fieldset disabled={!editable} className="space-y-3">
|
||||
<label className="block text-sm">
|
||||
{t("name")}
|
||||
<input
|
||||
className="input mt-1 w-full"
|
||||
maxLength={128}
|
||||
value={name}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{saved.mode === "copy" && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm">
|
||||
{t("targetParent")}:{" "}
|
||||
{categories.find((page) => page.id === target)
|
||||
?.caption ??
|
||||
(target === -1 ? t("rootCategory") : `#${target}`)}
|
||||
</summary>
|
||||
<PackageCategoryPicker
|
||||
pages={categories}
|
||||
selected={[target]}
|
||||
onChange={(ids) => {
|
||||
setTarget(ids[0]);
|
||||
invalidate();
|
||||
}}
|
||||
root
|
||||
/>
|
||||
</details>
|
||||
)}
|
||||
</fieldset>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant={tab === "edit" ? "default" : "outline"}
|
||||
onClick={() => setTab("edit")}
|
||||
>
|
||||
{t("editDraft")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "preview" ? "default" : "outline"}
|
||||
onClick={() => setTab("preview")}
|
||||
>
|
||||
{t("catalogPreview")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={tab === "review" ? "default" : "outline"}
|
||||
onClick={() => setTab("review")}
|
||||
>
|
||||
{t("changes")}
|
||||
</Button>
|
||||
</div>
|
||||
{tab === "edit" && (
|
||||
<PackageSnapshotEditor
|
||||
key={saved.id}
|
||||
snapshot={draft}
|
||||
disabled={!editable}
|
||||
revision={revision}
|
||||
onChange={(value) => {
|
||||
setDraft(value);
|
||||
invalidate();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{tab === "preview" && (
|
||||
<section className="space-y-3">
|
||||
<label className="flex gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={previewBaseline}
|
||||
onChange={(event) =>
|
||||
setPreviewBaseline(event.target.checked)
|
||||
}
|
||||
/>
|
||||
{t("showBaseline")}
|
||||
</label>
|
||||
<CatalogPackagePreview
|
||||
snapshot={previewBaseline ? saved.baseline : draft}
|
||||
baseline={previewBaseline ? undefined : saved.baseline}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
{tab === "review" && (
|
||||
<section className="space-y-3">
|
||||
<h3 className="font-semibold">{t("changes")}</h3>
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
saved.mode === "copy" ? "copyReview" : "updateReview",
|
||||
)}
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left">{t("entry")}</th>
|
||||
<th>{t("field")}</th>
|
||||
<th>{t("before")}</th>
|
||||
<th>{t("after")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{changes
|
||||
.slice(reviewPage * 20, (reviewPage + 1) * 20)
|
||||
.map((change) => (
|
||||
<tr
|
||||
key={change.key}
|
||||
className="border-t border-[var(--admin-border)]"
|
||||
>
|
||||
<th className="max-w-48 break-words p-2 text-left font-normal">
|
||||
{t(change.kind)} #{change.id} {change.label}
|
||||
</th>
|
||||
<td className="p-2">{t(change.field)}</td>
|
||||
<td className="max-w-48 break-words p-2">
|
||||
{change.before}
|
||||
</td>
|
||||
<td className="max-w-48 break-words p-2">
|
||||
{change.after}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{!changes.length && (
|
||||
<p className="text-sm">{t("noChanges")}</p>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!reviewPage}
|
||||
onClick={() => setReviewPage(reviewPage - 1)}
|
||||
>
|
||||
{t("previous")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={(reviewPage + 1) * 20 >= changes.length}
|
||||
onClick={() => setReviewPage(reviewPage + 1)}
|
||||
>
|
||||
{t("next")}
|
||||
</Button>
|
||||
</div>
|
||||
{review && (
|
||||
<div className="space-y-2 rounded border border-[var(--admin-border)] p-3">
|
||||
<p>
|
||||
{t("publicationImpact", {
|
||||
pages: review.changedPages,
|
||||
offers: review.changedOffers,
|
||||
})}
|
||||
</p>
|
||||
{review.conflicts.length ? (
|
||||
<>
|
||||
<p role="alert">{t("conflicts")}</p>
|
||||
<ul className="list-inside list-disc text-sm">
|
||||
{[...new Set(review.conflicts)]
|
||||
.slice(0, 50)
|
||||
.map((conflict) => (
|
||||
<li key={conflict}>{conflict}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
) : (
|
||||
<p>{t("readyToPublish")}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{publication?.warnings?.map((warning) => (
|
||||
<p
|
||||
key={warning}
|
||||
role="status"
|
||||
className="text-sm text-[var(--admin-warning)]"
|
||||
>
|
||||
{warning}
|
||||
</p>
|
||||
))}
|
||||
{editable && (
|
||||
<div className="sticky bottom-0 flex flex-wrap gap-2 border-t border-[var(--admin-border)] bg-[var(--admin-surface)] py-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={!dirty}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{t("saveDraft")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={dirty}
|
||||
onClick={() => void requestReview()}
|
||||
>
|
||||
{t("reviewPublication")}
|
||||
</Button>
|
||||
{review && !review.conflicts.length && (
|
||||
<Button disabled={dirty} onClick={() => void publish()}>
|
||||
{t("publish")}
|
||||
</Button>
|
||||
)}
|
||||
<p className="w-full text-xs text-[var(--admin-text-muted)]">
|
||||
{t("saveHint")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</fieldset>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user