import { useEffect, useRef, useState } from 'react'; import type { Key } from 'react'; import Tree from 'antd/es/tree'; import Button from 'antd/es/button'; import Input from 'antd/es/input'; import Modal from 'antd/es/modal'; import Select from 'antd/es/select'; import Space from 'antd/es/space'; import Typography from 'antd/es/typography'; import Empty from 'antd/es/empty'; import Spin from 'antd/es/spin'; import message from 'antd/es/message'; import type { DataNode, TreeProps } from 'antd/es/tree'; import { Category, fetchAdminCategories, createCategory, updateCategory, deleteCategory } from '../api'; import { buildCategoryTree, categoryPath, CategoryNode } from '../filters'; const { Title, Text } = Typography; // Counts the whole branch, so the delete confirmation can say what a parent // takes down with it rather than only naming itself. function branchTotals(node: CategoryNode): { categories: number; items: number } { return node.children.reduce( (acc, child) => { const nested = branchTotals(child); return { categories: acc.categories + nested.categories, items: acc.items + nested.items }; }, { categories: 1, items: node.item_count } ); } function findNode(nodes: CategoryNode[], id: number): CategoryNode | null { for (const node of nodes) { if (node.id === id) return node; const nested = findNode(node.children, id); if (nested) return nested; } return null; } export default function Categories() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [modalOpen, setModalOpen] = useState(false); const [editing, setEditing] = useState(null); const [name, setName] = useState(''); const [parentId, setParentId] = useState(null); // The tree stays mounted across reloads, so `defaultExpandAll` would only // ever apply to the categories present on first render — a branch added // afterwards would render collapsed and its children be unreachable. const [expandedKeys, setExpandedKeys] = useState([]); const expansionInitialized = useRef(false); const tree = buildCategoryTree(categories); function expand(id: number) { setExpandedKeys((keys) => (keys.includes(id) ? keys : [...keys, id])); } function load() { setLoading(true); return fetchAdminCategories() .then((list) => { setCategories(list); // Start fully expanded, then leave expansion to the user — reloading // must not silently re-open branches they collapsed. if (!expansionInitialized.current) { setExpandedKeys(list.map((category) => category.id)); expansionInitialized.current = true; } }) .finally(() => setLoading(false)); } useEffect(() => { load(); }, []); function openNew(parent: number | null) { setEditing(null); setName(''); setParentId(parent); setModalOpen(true); } function openEdit(category: Category) { setEditing(category); setName(category.name); setParentId(category.parent_id); setModalOpen(true); } async function handleSave() { const trimmed = name.trim(); if (!trimmed) { message.error('Name is required'); return; } try { if (editing) { await updateCategory(editing.id, { name: trimmed, parent_id: parentId }); message.success('Category updated'); } else { await createCategory(trimmed, parentId); message.success('Category added'); } // Reveal where the category just landed instead of filing it out of sight. if (parentId !== null) expand(parentId); setModalOpen(false); load(); } catch (err) { message.error((err as Error).message); } } function handleDelete(category: Category) { const node = findNode(tree, category.id); const totals = node ? branchTotals(node) : { categories: 1, items: category.item_count }; const subcategories = totals.categories - 1; Modal.confirm({ title: `Delete "${category.name}"?`, content: ( This deletes {subcategories === 0 ? 'no subcategories' : `${subcategories} subcategor${subcategories === 1 ? 'y' : 'ies'}`} {' '}and uncategorizes {totals.items} item{totals.items === 1 ? '' : 's'}. The items themselves are kept. ), okText: 'Delete', okButtonProps: { danger: true }, onOk: async () => { const result = await deleteCategory(category.id); message.success( `Deleted ${result.deleted_categories} categor${result.deleted_categories === 1 ? 'y' : 'ies'}, ` + `uncategorized ${result.uncategorized_items} item${result.uncategorized_items === 1 ? '' : 's'}` ); load(); } }); } // Dragging a node onto another reparents it. The server rejects a move that // would put a node beneath its own descendant, so a refused drop just // reloads the unchanged tree. const handleDrop: TreeProps['onDrop'] = async (info) => { const dragId = Number(info.dragNode.key); const dropId = Number(info.node.key); const dropToGap = !info.dropToGap ? dropId : findNode(tree, dropId)?.parent_id ?? null; try { await updateCategory(dragId, { parent_id: dropToGap }); if (dropToGap !== null) expand(dropToGap); message.success('Category moved'); } catch (err) { message.error((err as Error).message); } load(); }; function toTreeData(nodes: CategoryNode[]): DataNode[] { return nodes.map((node) => ({ key: node.id, title: ( {node.name} {node.item_count} item{node.item_count === 1 ? '' : 's'} event.stopPropagation()}> ), children: node.children.length ? toTreeData(node.children) : undefined })); } // A category may not be moved beneath itself or its own descendants, so those // options are withheld from the parent picker too. const forbidden = new Set(); if (editing) { const collect = (node: CategoryNode) => { forbidden.add(node.id); node.children.forEach(collect); }; const node = findNode(tree, editing.id); if (node) collect(node); } const parentOptions = [ { value: null as number | null, label: 'No parent (top level)' }, ...categories .filter((category) => !forbidden.has(category.id)) .map((category) => ({ value: category.id as number | null, label: categoryPath(categories, category.id) })) ]; return (
Categories
{loading && !categories.length ? : null} {!loading && !categories.length ? ( ) : ( )} setModalOpen(false)} destroyOnHidden > setName(event.target.value)} onPressEnter={handleSave} />