633 lines
19 KiB
TypeScript
633 lines
19 KiB
TypeScript
"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>
|
|
</>
|
|
);
|
|
}
|