refactor(catalog): unify commands and embed guarded catalog workspace

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -4,7 +4,11 @@ import { ChevronRight, Home } from "lucide-react";
import { Fragment, useEffect } from "react";
import { useCatalogManager } from "./use-catalog-store";
export function BreadcrumbBar() {
export function BreadcrumbBar({
onSelectPage,
}: {
onSelectPage: (id: number) => void;
}) {
const { state, dispatch, catalogType } = useCatalogManager();
const { selectedPageId, ancestors } = state;
const catQs = catalogType === "bc" ? "&catalog=bc" : "";
@@ -40,7 +44,7 @@ export function BreadcrumbBar() {
<ChevronRight className="h-3 w-3 shrink-0" />
<button
type="button"
onClick={() => dispatch({ type: "SELECT_PAGE", id: a.id })}
onClick={() => onSelectPage(a.id)}
className="shrink-0 hover:text-foreground transition-colors truncate max-w-40"
>
{a.caption}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -22,12 +22,12 @@ import { useCallback, useState } from "react";
import { toast } from "sonner";
import {
deleteTreePage,
reorderTreePage,
reorderCatalogPages,
toggleCatalogPage,
} from "@/actions/catalog";
import {
deleteBcTreePage,
reorderBcTreePage,
reorderBcCatalogPages,
toggleBcPage,
} from "@/actions/catalog-bc";
import { CatalogIcon } from "@/components/admin/catalog-tree";
@@ -40,6 +40,7 @@ import {
ContextMenuSeparator,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { requestCatalogNavigation } from "@/features/catalog/client/use-catalog-navigation";
import { cn } from "@/lib/utils";
import { useCatalogManager } from "./use-catalog-store";
@@ -54,6 +55,7 @@ interface RootTabsProps {
onEditTab: (id: number) => void | Promise<void>;
onCreateRoot: () => void;
loading?: boolean;
onMutation?: () => void;
}
export function RootTabs({
@@ -63,6 +65,7 @@ export function RootTabs({
onEditTab,
onCreateRoot,
loading,
onMutation,
}: RootTabsProps) {
const { catalogType, canEdit } = useCatalogManager();
const { confirm, dialog: confirmDialog } = useConfirmDialog();
@@ -85,26 +88,26 @@ export function RootTabs({
const newIndex = tabs.findIndex((t) => t.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
if (!canEdit) return;
if (!canEdit || !requestCatalogNavigation()) return;
const newOrder = arrayMove(tabs, oldIndex, newIndex);
// Batch reorder
try {
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
const results = await Promise.allSettled(
newOrder.map((tab, idx) =>
reorder({ pageId: tab.id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed) toast.error(`${failed} reorder(s) failed`);
else toast.success("Tabs reordered");
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
const result = await reorder({
parentId: -1,
ids: newOrder.map((tab) => tab.id),
expectedIds: tabs.map((tab) => tab.id),
});
if (!result.ok) throw Error("Failed to reorder tabs");
toast.success("Tabs reordered");
onMutation?.();
} catch {
toast.error("Failed to reorder tabs");
}
},
[tabs, canEdit, catalogType],
[tabs, canEdit, catalogType, onMutation],
);
const handleToggle = async (tab: RootTab) => {
@@ -118,6 +121,7 @@ export function RootTabs({
toast.success(
`${tab.caption} ${tab.enabled === "1" ? "disabled" : "enabled"}`,
);
onMutation?.();
} catch {
toast.error("Failed to toggle");
}
@@ -138,6 +142,7 @@ export function RootTabs({
await deleteTreePage({ pageId: tab.id, mode: "reparent" });
}
toast.success(`"${tab.caption}" deleted`);
onMutation?.();
} catch {
toast.error("Failed to delete");
}
@@ -36,6 +36,7 @@ import { toast } from "sonner";
import {
createCatalogPage,
deleteTreePage,
reorderCatalogPages,
reorderTreePage,
toggleCatalogPage,
updateCatalogPage,
@@ -43,6 +44,7 @@ import {
import {
createBcPage,
deleteBcTreePage,
reorderBcCatalogPages,
reorderBcTreePage,
toggleBcPage,
updateBcPage,
@@ -113,7 +115,6 @@ export function SortableTree({
const [subpageName, setSubpageName] = useState("");
const [creatingSubpage, setCreatingSubpage] = useState(false);
const [searching, setSearching] = useState(false);
const searchTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const [activeId, setActiveId] = useState<number | null>(null);
const [treeLoading, setTreeLoading] = useState(false);
const [filter, setFilter] = useState<FilterMode>("all");
@@ -189,29 +190,32 @@ export function SortableTree({
void loadTabTree();
}, [activeTabId, loadTabTree, dispatch]);
// Debounced search
// Ignore stale search completions when the query or catalog changes.
useEffect(() => {
if (!searchQuery.trim()) {
setSearchResults(null);
return;
}
if (searchTimeout.current) clearTimeout(searchTimeout.current);
searchTimeout.current = setTimeout(async () => {
const controller = new AbortController();
setSearchResults(null);
setSearching(false);
if (!searchQuery.trim()) return;
const timeout = setTimeout(async () => {
setSearching(true);
try {
const res = await fetch(
`/api/admin/catalog/tree?search=${encodeURIComponent(searchQuery.trim())}${catQs}`,
{ signal: controller.signal },
);
if (res.ok) {
const data = await res.json();
setSearchResults(data.pages || []);
}
if (!res.ok) throw new Error("Search failed");
const data = await res.json();
if (!controller.signal.aborted) setSearchResults(data.pages || []);
} catch {
if (!controller.signal.aborted)
toast.error("Failed to search catalog pages");
} finally {
setSearching(false);
if (!controller.signal.aborted) setSearching(false);
}
}, 300);
return () => {
if (searchTimeout.current) clearTimeout(searchTimeout.current);
controller.abort();
clearTimeout(timeout);
};
}, [searchQuery, catQs]);
@@ -423,7 +427,8 @@ export function SortableTree({
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
if (!over || active.id === over.id || activeTabId === null) return;
if (!canEdit || !over || active.id === over.id || activeTabId === null)
return;
const activeNode = nodes.get(active.id as number);
const overNode = nodes.get(over.id as number);
@@ -446,16 +451,15 @@ export function SortableTree({
if (!canEdit) return;
const reorder =
catalogType === "bc" ? reorderBcTreePage : reorderTreePage;
catalogType === "bc" ? reorderBcCatalogPages : reorderCatalogPages;
try {
const results = await Promise.allSettled(
newOrder.map((id, idx) =>
reorder({ pageId: id, newOrderNum: idx + 1 }),
),
);
const failed = results.filter((r) => r.status === "rejected").length;
if (failed > 0) toast.error(`${failed} reorder(s) failed`);
else toast.success("Reordered");
const result = await reorder({
parentId,
ids: newOrder,
expectedIds: siblings,
});
if (!result.ok) throw Error("Failed to reorder");
toast.success("Reordered");
onTreeMutation();
} catch {
toast.error("Failed to reorder");
@@ -317,6 +317,7 @@ export const TreeItemContent = React.memo(
},
(prev, next) => {
return (
prev.onSelect === next.onSelect &&
prev.node.id === next.node.id &&
prev.node.caption === next.node.caption &&
prev.node.enabled === next.node.enabled &&
@@ -59,6 +59,7 @@ const initialState: CatalogManagerState = {
// ── Actions ───────────────────────────────────────────────────
export type CatalogAction =
| { type: "RESTORE_SELECTION"; rootId: number | null; pageId: number | null }
| { type: "SET_ROOT_PAGES"; pages: RootTab[] }
| { type: "SET_LOADING_ROOT"; loading: boolean }
| { type: "SET_ACTIVE_TAB"; id: number | null }
@@ -93,14 +94,33 @@ function reducer(
action: CatalogAction,
): CatalogManagerState {
switch (action.type) {
case "RESTORE_SELECTION":
return {
...state,
activeTabId:
action.rootId ?? state.rootPages[0]?.id ?? state.activeTabId,
selectedPageId: action.pageId,
focusedId: action.pageId,
editorDirty: false,
multiSelectedIds: new Set(),
ancestors: [],
};
case "SET_ROOT_PAGES":
return { ...state, rootPages: action.pages, loadingRoot: false };
return {
...state,
rootPages: action.pages,
loadingRoot: false,
activeTabId: action.pages.some((page) => page.id === state.activeTabId)
? state.activeTabId
: (action.pages[0]?.id ?? null),
};
case "SET_LOADING_ROOT":
return { ...state, loadingRoot: action.loading };
case "SET_ACTIVE_TAB":
return {
...state,
activeTabId: action.id,
editorDirty: false,
selectedPageId: null,
multiSelectedIds: new Set(),
focusedId: null,
@@ -110,6 +130,7 @@ function reducer(
return {
...state,
selectedPageId: action.id,
editorDirty: false,
multiSelectedIds: new Set(),
focusedId: action.id,
};
@@ -1,55 +0,0 @@
"use client";
import { AlertTriangle } from "lucide-react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { toast } from "sonner";
import { fixEverythingAction } from "@/actions/furni-maintenance";
import { Button } from "@/components/ui/button";
/**
* One-click "fix everything" for the furniture tables. Runs sprite fix, catalog
* offer fix, FurnitureData reconciliation, dedup and ID alignment in order, all
* inside a single DB transaction on the server. Heavy — guarded by a confirm.
*/
export function CatalogFixAllButton() {
const router = useRouter();
const [busy, setBusy] = useState(false);
async function run() {
if (
!window.confirm(
"Alle meubel-tabellen repareren (sprite-id, offer_id, dubbelen, IDs naar FurnitureData)?\nDit is een zware, niet-omkeerbare actie. Zorg dat er een backup is.",
)
) {
return;
}
setBusy(true);
try {
const res = await fixEverythingAction();
if (res.ok) {
toast.success("Alle reparaties uitgevoerd");
router.refresh();
} else {
toast.error(res.error ?? "Mislukt");
}
} catch (e) {
toast.error(String(e));
} finally {
setBusy(false);
}
}
return (
<Button
variant="destructive"
size="sm"
disabled={busy}
onClick={() => void run()}
title="Alle meubel-tabellen repareren"
>
<AlertTriangle className="mr-2 h-4 w-4" />
{busy ? "Bezig…" : "Fix alles"}
</Button>
);
}