Files
Epicnabbo-Catalogus-Updated…/src/features/catalog/components/packages/catalog-packages-dialog.tsx
T

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>
</>
);
}