feat(catalog): record bulk edits, make deletions reversible, unify the tree read
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 28s
CI / tests-integration (push) Successful in 1m43s
CI / tests-unit (push) Successful in 1m48s
CI / tests-ui (push) Successful in 2m37s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m23s
A bulk offer edit is the catalog mutation that rewrites hundreds of rows at once, and it was the only one writing nothing to the staff activity log: 22 of the 43 catalog actions logged, this one did not. The entry it now writes says what changed, not just that something did, because the log has no undo of its own and "bulk updated 200 offers" cannot answer the question it exists for. Deleting offers had no inverse at all. Every removed row is now kept at delete time and the caller gets a restoreId back, so an accidental multi-select is a click rather than a hand-edit of the table. The undo toast covers the common case; a RecentDeletionsPanel holds the same records so a delete noticed later is still reachable. Three refusals guard it: an id that another offer has since taken, a category that no longer exists (which would leave an offer that sells nowhere and shows under no page), and a delete whose restore record cannot be written — that one rolls back rather than deleting without a way back. Reading the audit row FOR UPDATE is also what stops two restores of one deletion from both inserting. sendCatalogUpdate() overwrote hotel-status.json on every write, so "which imports reached the hotel" was answerable for the last attempt only, and a failure two imports ago was gone by the time anyone looked. That file is now also appended to as a bounded 50-entry tail. The tree route carried four copies of the same page-select-plus-counts shaping, of which the BC branches had already drifted: one counted offers through the VARCHAR-tolerant helper, the other inline and swallowing errors. All of it is one readPages() now, and readFullTree sends both catalogs through one depth computation instead of delegating normal to getTreeFlat while computing BC here — a split that left two implementations behind one function name. getTreeFlat is gone. The BC ancestor walk also went from 20 levels to 50, matching getAncestors, so a deeply nested catalog no longer loses its breadcrumb. Bulk editing reaches the BC catalog, which previously had no way to edit or duplicate offers in bulk. The catalog is part of the operation identity now, so replaying one request key against the other catalog is not mistaken for the same work. Integration tests failed to import: the next/cache mock supplied only revalidatePath, and catalog-totals calls unstable_cache at module scope.
This commit is contained in:
1 parent
9550b3d66f
commit
cebcf440c5
20 files changed
+1780
-494
No files matched your search
@@ -830,6 +830,7 @@ function InlineEditorSession({
|
||||
interactionTypes={itemsData.interactionTypes}
|
||||
furniRevisionMap={itemsData.furniRevisionMap}
|
||||
allPages={itemsData.allPages}
|
||||
catalog={catalogType}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
deleteCatalogItems,
|
||||
moveCatalogItems,
|
||||
reorderCatalogItems,
|
||||
restoreDeletedCatalogItems,
|
||||
updateCatalogItem,
|
||||
} from "@/actions/catalog-items";
|
||||
import { BulkImportItems } from "@/components/admin/catalog/bulk-import-items";
|
||||
@@ -97,6 +98,7 @@ import {
|
||||
ToggleField,
|
||||
} from "./field-helpers";
|
||||
import { FurniIcon } from "./furni-icon";
|
||||
import { RecentDeletionsPanel } from "./recent-deletions-panel";
|
||||
import { SongPicker } from "./song-picker";
|
||||
import {
|
||||
type BaseItemData,
|
||||
@@ -168,6 +170,8 @@ interface CatalogItemsTableProps {
|
||||
interactionTypes: string[];
|
||||
furniRevisionMap: Record<number, { classname: string; revision: number }>;
|
||||
allPages: { id: number; caption: string }[];
|
||||
/** Which catalog these offers belong to; bulk editing follows it. */
|
||||
catalog?: "normal" | "bc";
|
||||
}
|
||||
|
||||
export function CatalogItemsTable({
|
||||
@@ -181,6 +185,7 @@ export function CatalogItemsTable({
|
||||
interactionTypes,
|
||||
furniRevisionMap,
|
||||
allPages,
|
||||
catalog = "normal",
|
||||
}: CatalogItemsTableProps) {
|
||||
const bulk = useTranslations("pages.admin.catalog.bulk");
|
||||
const discovery = useTranslations("pages.admin.catalog.discovery");
|
||||
@@ -192,6 +197,33 @@ export function CatalogItemsTable({
|
||||
>(null);
|
||||
const { run, isPending } = useServerAction();
|
||||
const { confirm, dialog: confirmDialog } = useConfirmDialog();
|
||||
/**
|
||||
* A delete keeps every removed row, so it can be taken back. The affordance
|
||||
* lives in the toast rather than in the toolbar because the window is short:
|
||||
* the audit record outlives it, but a bulk delete is not something to restore
|
||||
* from a dropdown ten minutes later.
|
||||
*/
|
||||
function offerUndoDelete(restoreId: number, count: number) {
|
||||
if (!Number.isSafeInteger(restoreId) || restoreId < 1) return;
|
||||
// The panel below holds the same records, so a restore missed here is not a
|
||||
// lost delete.
|
||||
toast.success(`Deleted ${count} item(s).`, {
|
||||
action: {
|
||||
label: "Undo",
|
||||
onClick: () => {
|
||||
void restoreDeletedCatalogItems({
|
||||
restoreId,
|
||||
requestKey: crypto.randomUUID(),
|
||||
}).then((result) => {
|
||||
onRefresh();
|
||||
if (!result.ok)
|
||||
toast.error(result.error ?? "Could not restore the items.");
|
||||
else toast.success(`Restored ${result.data.restored} item(s).`);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
const [editingItem, setEditingItem] = useState<CatalogItemData | null>(null);
|
||||
const [editingBase, setEditingBase] = useState<BaseItemData | null>(null);
|
||||
@@ -531,12 +563,14 @@ export function CatalogItemsTable({
|
||||
});
|
||||
if (!ok) return;
|
||||
|
||||
run(() => deleteCatalogItems({ ids: [...selected] }), {
|
||||
successMessage: `Deleted ${selected.size} item(s).`,
|
||||
const ids = [...selected];
|
||||
run(() => deleteCatalogItems({ ids, requestKey: crypto.randomUUID() }), {
|
||||
successMessage: `Deleted ${ids.length} item(s).`,
|
||||
errorMessage: "Failed to delete items.",
|
||||
onSuccess: () => {
|
||||
onSuccess: (data) => {
|
||||
setSelected(new Set());
|
||||
onRefresh();
|
||||
offerUndoDelete(Number(data.restoreId), ids.length);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -684,11 +718,17 @@ export function CatalogItemsTable({
|
||||
confirmLabel: "Delete",
|
||||
});
|
||||
if (!ok) return;
|
||||
run(() => deleteCatalogItems({ ids: [id] }), {
|
||||
successMessage: "Item deleted.",
|
||||
errorMessage: "Failed to delete item.",
|
||||
onSuccess: () => onRefresh(),
|
||||
});
|
||||
run(
|
||||
() => deleteCatalogItems({ ids: [id], requestKey: crypto.randomUUID() }),
|
||||
{
|
||||
successMessage: "Item deleted.",
|
||||
errorMessage: "Failed to delete item.",
|
||||
onSuccess: (data) => {
|
||||
onRefresh();
|
||||
offerUndoDelete(Number(data.restoreId), 1);
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── Move single item to another page ─────────────────────────
|
||||
@@ -805,6 +845,16 @@ export function CatalogItemsTable({
|
||||
return (
|
||||
<>
|
||||
{confirmDialog}
|
||||
{canEdit && (
|
||||
<details className="mb-3">
|
||||
<summary className="cursor-pointer text-sm text-[var(--admin-text-muted)]">
|
||||
Recent deletions
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<RecentDeletionsPanel onRestored={onRefresh} />
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
{bulkRefreshFrom !== null && (
|
||||
<p
|
||||
role="status"
|
||||
@@ -875,6 +925,7 @@ export function CatalogItemsTable({
|
||||
<BulkOfferEditor
|
||||
ids={[...selected]}
|
||||
allPages={allPages}
|
||||
catalog={catalog}
|
||||
disabled={
|
||||
hasUnsavedChanges ||
|
||||
isPending ||
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, RotateCcw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { listRestorableCatalogItemDeletions } from "@/actions/catalog-items";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface RestorableDeletion {
|
||||
restoreId: number;
|
||||
offers: number;
|
||||
deletedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The undo toast only lives a few seconds. A delete noticed after that window is
|
||||
* still recoverable — the rows are kept until they are restored — so the offer
|
||||
* has to be reachable here too. Without this panel the only way back into a
|
||||
* past deletion is editing the database by hand.
|
||||
*/
|
||||
export function RecentDeletionsPanel({
|
||||
onRestored,
|
||||
}: {
|
||||
onRestored: () => void;
|
||||
}) {
|
||||
const [entries, setEntries] = useState<RestorableDeletion[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [restoring, setRestoring] = useState<number | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
try {
|
||||
const result = await listRestorableCatalogItemDeletions();
|
||||
if (result.ok) setEntries(result.data);
|
||||
else setFailed(true);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
async function restore(entry: RestorableDeletion) {
|
||||
if (restoring !== null) return;
|
||||
setRestoring(entry.restoreId);
|
||||
try {
|
||||
const { restoreDeletedCatalogItems } = await import(
|
||||
"@/actions/catalog-items"
|
||||
);
|
||||
const result = await restoreDeletedCatalogItems({
|
||||
restoreId: entry.restoreId,
|
||||
requestKey: crypto.randomUUID(),
|
||||
});
|
||||
if (!result.ok) {
|
||||
toast.error(result.error ?? "Could not restore those offers.");
|
||||
// The reason is usually that an id got reused, which is permanent:
|
||||
// re-listing so the entry stops offering a restore that cannot work.
|
||||
await load();
|
||||
return;
|
||||
}
|
||||
toast.success(`Restored ${result.data.restored} offer(s).`);
|
||||
onRestored();
|
||||
await load();
|
||||
} catch {
|
||||
toast.error("Could not restore those offers.");
|
||||
} finally {
|
||||
setRestoring(null);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<p className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 aria-hidden className="h-4 w-4 animate-spin" />
|
||||
Loading recent deletions…
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (failed) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-[var(--admin-error)]">
|
||||
Could not load recent deletions.
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => void load()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!entries.length) return null;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-[var(--admin-border)] p-2">
|
||||
<p className="mb-1.5 text-xs font-medium text-[var(--admin-text-muted)]">
|
||||
Recent deletions — restorable
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{entries.map((entry) => (
|
||||
<li
|
||||
key={entry.restoreId}
|
||||
className="flex items-center justify-between gap-3 text-sm"
|
||||
>
|
||||
<span className="min-w-0 truncate">
|
||||
{entry.offers} offer(s) ·{" "}
|
||||
<time dateTime={entry.deletedAt}>
|
||||
{new Date(entry.deletedAt).toLocaleString()}
|
||||
</time>
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={restoring !== null}
|
||||
onClick={() => void restore(entry)}
|
||||
>
|
||||
{restoring === entry.restoreId && (
|
||||
<RotateCcw aria-hidden className="mr-2 h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Restore
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user