Self-review of the reload: updateCategorySortOrders writes one UPDATE per category, each through the lock on its own, so a check opened right after a drag-and-drop could read halfway and show half the move in the tree. moveCategory keeps its write's promise, settled either way, and checkHierarchy waits for it before reading. Pinned by a hook test with the write held open. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
536 lines
18 KiB
TypeScript
536 lines
18 KiB
TypeScript
import { useReducer, useCallback, useEffect, useRef } from "react";
|
|
import type {
|
|
CategoryTreeNode,
|
|
CategoryFormData,
|
|
Keyword,
|
|
} from "../shared/types";
|
|
import {
|
|
getAllCategoriesWithCounts,
|
|
createCategory,
|
|
updateCategory,
|
|
deactivateCategory,
|
|
getCategoryUsageCount,
|
|
getChildrenUsageCount,
|
|
getKeywordsByCategoryId,
|
|
createKeyword,
|
|
updateKeyword,
|
|
deactivateKeyword,
|
|
reinitializeCategories as reinitializeCategoriesSvc,
|
|
hasDuplicateSortOrders,
|
|
fixDuplicateSortOrders,
|
|
getNextSortOrder,
|
|
updateCategorySortOrders,
|
|
findOrphanCategories,
|
|
reattachCategory,
|
|
CategoryHierarchyError,
|
|
categoryHierarchyErrorKey,
|
|
} from "../services/categoryService";
|
|
import { logInfo } from "../services/logService";
|
|
import type { OrphanCategory } from "../utils/categoryHierarchy";
|
|
|
|
/**
|
|
* What a failed action shows: a refusal of the hierarchy rules by the i18n key
|
|
* of its code (#393), any other failure by its message.
|
|
*/
|
|
export type CategoriesError = { key: string } | { message: string };
|
|
|
|
export function categoriesErrorOf(e: unknown): CategoriesError {
|
|
if (e instanceof CategoryHierarchyError) return { key: categoryHierarchyErrorKey(e.code) };
|
|
return { message: e instanceof Error ? e.message : String(e) };
|
|
}
|
|
|
|
/** The text of a categories error: the translation of its key, or its message. */
|
|
export function categoriesErrorText(error: CategoriesError, t: (key: string) => string): string {
|
|
return "key" in error ? t(error.key) : error.message;
|
|
}
|
|
|
|
/** The « Vérifier la hiérarchie » window (#393), while it is open. */
|
|
export interface HierarchyCheckState {
|
|
/** The orphan categories, one per group to reattach; `null` while the check runs. */
|
|
orphans: OrphanCategory[] | null;
|
|
/** A refused reattachment, or a check that failed. */
|
|
error: CategoriesError | null;
|
|
}
|
|
|
|
interface CategoriesState {
|
|
categories: CategoryTreeNode[];
|
|
tree: CategoryTreeNode[];
|
|
selectedCategoryId: number | null;
|
|
keywords: Keyword[];
|
|
isLoading: boolean;
|
|
isSaving: boolean;
|
|
error: CategoriesError | null;
|
|
editingCategory: CategoryFormData | null;
|
|
isCreating: boolean;
|
|
/** `null` while the window is closed. */
|
|
hierarchyCheck: HierarchyCheckState | null;
|
|
}
|
|
|
|
type CategoriesAction =
|
|
| { type: "SET_LOADING"; payload: boolean }
|
|
| { type: "SET_SAVING"; payload: boolean }
|
|
| { type: "SET_ERROR"; payload: CategoriesError | null }
|
|
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
|
|
| { type: "SET_TREE"; payload: CategoryTreeNode[] }
|
|
| { type: "SELECT_CATEGORY"; payload: number | null }
|
|
| { type: "SET_KEYWORDS"; payload: Keyword[] }
|
|
| { type: "START_CREATING" }
|
|
| { type: "START_EDITING"; payload: CategoryFormData }
|
|
| { type: "CANCEL_EDITING" }
|
|
| { type: "OPEN_HIERARCHY_CHECK" }
|
|
| { type: "SET_ORPHANS"; payload: OrphanCategory[] }
|
|
| { type: "START_REATTACH" }
|
|
| { type: "SET_HIERARCHY_ERROR"; payload: CategoriesError }
|
|
| { type: "CLOSE_HIERARCHY_CHECK" };
|
|
|
|
const initialState: CategoriesState = {
|
|
categories: [],
|
|
tree: [],
|
|
selectedCategoryId: null,
|
|
keywords: [],
|
|
isLoading: false,
|
|
isSaving: false,
|
|
error: null,
|
|
editingCategory: null,
|
|
isCreating: false,
|
|
hierarchyCheck: null,
|
|
};
|
|
|
|
/**
|
|
* The tree of the Categories page: each category under its parent, a category
|
|
* whose parent is not in the list at the root. A category caught in a
|
|
* `parent_id` cycle is under a parent and never at the root, so no root leads
|
|
* to it: « Vérifier la hiérarchie » finds it (#393).
|
|
*/
|
|
export function buildTree(flat: CategoryTreeNode[]): CategoryTreeNode[] {
|
|
const map = new Map<number, CategoryTreeNode>();
|
|
const roots: CategoryTreeNode[] = [];
|
|
|
|
for (const cat of flat) {
|
|
map.set(cat.id, { ...cat, children: [] });
|
|
}
|
|
|
|
for (const cat of map.values()) {
|
|
if (cat.parent_id && map.has(cat.parent_id)) {
|
|
map.get(cat.parent_id)!.children.push(cat);
|
|
} else {
|
|
roots.push(cat);
|
|
}
|
|
}
|
|
|
|
return roots;
|
|
}
|
|
|
|
function flattenTreeToCategories(tree: CategoryTreeNode[]): CategoryTreeNode[] {
|
|
const result: CategoryTreeNode[] = [];
|
|
function recurse(nodes: CategoryTreeNode[]) {
|
|
for (const node of nodes) {
|
|
result.push(node);
|
|
if (node.children.length > 0) {
|
|
recurse(node.children);
|
|
}
|
|
}
|
|
}
|
|
recurse(tree);
|
|
return result;
|
|
}
|
|
|
|
function reducer(state: CategoriesState, action: CategoriesAction): CategoriesState {
|
|
switch (action.type) {
|
|
case "SET_LOADING":
|
|
return { ...state, isLoading: action.payload };
|
|
case "SET_SAVING":
|
|
return { ...state, isSaving: action.payload };
|
|
case "SET_ERROR":
|
|
return { ...state, error: action.payload, isLoading: false, isSaving: false };
|
|
case "SET_CATEGORIES":
|
|
return { ...state, categories: action.payload.flat, tree: action.payload.tree, isLoading: false };
|
|
case "SET_TREE":
|
|
return { ...state, tree: action.payload, categories: flattenTreeToCategories(action.payload) };
|
|
case "SELECT_CATEGORY":
|
|
return { ...state, selectedCategoryId: action.payload, editingCategory: null, isCreating: false, keywords: [] };
|
|
case "SET_KEYWORDS":
|
|
return { ...state, keywords: action.payload };
|
|
case "START_CREATING":
|
|
return {
|
|
...state,
|
|
isCreating: true,
|
|
selectedCategoryId: null,
|
|
editingCategory: { name: "", type: "expense", color: "#4A90A4", parent_id: null, is_inputable: true, sort_order: 0 },
|
|
keywords: [],
|
|
};
|
|
case "START_EDITING":
|
|
return { ...state, isCreating: false, editingCategory: action.payload };
|
|
case "CANCEL_EDITING":
|
|
return { ...state, editingCategory: null, isCreating: false };
|
|
case "OPEN_HIERARCHY_CHECK":
|
|
return { ...state, hierarchyCheck: { orphans: null, error: null } };
|
|
case "SET_ORPHANS":
|
|
// A check that ends after the window closed changes nothing.
|
|
return state.hierarchyCheck
|
|
? { ...state, hierarchyCheck: { ...state.hierarchyCheck, orphans: action.payload } }
|
|
: state;
|
|
case "START_REATTACH":
|
|
return {
|
|
...state,
|
|
isSaving: true,
|
|
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: null } : null,
|
|
};
|
|
case "SET_HIERARCHY_ERROR":
|
|
return {
|
|
...state,
|
|
isSaving: false,
|
|
hierarchyCheck: state.hierarchyCheck ? { ...state.hierarchyCheck, error: action.payload } : null,
|
|
};
|
|
case "CLOSE_HIERARCHY_CHECK":
|
|
return { ...state, hierarchyCheck: null };
|
|
default:
|
|
return state;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The Journal line of a reattachment (#393): the reason code and the number of
|
|
* categories put back in place, never a category name.
|
|
*/
|
|
export function reattachLogLine(reason: OrphanCategory["reason"], restored: number): string {
|
|
return (
|
|
`Categories: orphan category reattached (${reason}), ` +
|
|
`${restored} ${restored === 1 ? "category" : "categories"} back in the hierarchy`
|
|
);
|
|
}
|
|
|
|
export function useCategories() {
|
|
const [state, dispatch] = useReducer(reducer, initialState);
|
|
const fetchIdRef = useRef(0);
|
|
const duplicateCheckDone = useRef(false);
|
|
/** The writes of the last drag-and-drop, settled or not: the hierarchy check reads after them. */
|
|
const pendingMoveRef = useRef<Promise<unknown>>(Promise.resolve());
|
|
|
|
const loadCategories = useCallback(async () => {
|
|
const fetchId = ++fetchIdRef.current;
|
|
dispatch({ type: "SET_LOADING", payload: true });
|
|
dispatch({ type: "SET_ERROR", payload: null });
|
|
|
|
try {
|
|
if (!duplicateCheckDone.current) {
|
|
duplicateCheckDone.current = true;
|
|
const hasDups = await hasDuplicateSortOrders();
|
|
if (hasDups) {
|
|
await fixDuplicateSortOrders();
|
|
}
|
|
}
|
|
|
|
const rows = await getAllCategoriesWithCounts();
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] }));
|
|
const tree = buildTree(flat);
|
|
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree } });
|
|
} catch (e) {
|
|
if (fetchId !== fetchIdRef.current) return;
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
loadCategories();
|
|
}, [loadCategories]);
|
|
|
|
const selectCategory = useCallback(async (id: number | null) => {
|
|
dispatch({ type: "SELECT_CATEGORY", payload: id });
|
|
if (id !== null) {
|
|
try {
|
|
const kws = await getKeywordsByCategoryId(id);
|
|
dispatch({ type: "SET_KEYWORDS", payload: kws });
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
}
|
|
}, []);
|
|
|
|
const startCreating = useCallback(() => {
|
|
dispatch({ type: "START_CREATING" });
|
|
}, []);
|
|
|
|
const startEditing = useCallback(() => {
|
|
const cat = state.categories.find((c) => c.id === state.selectedCategoryId);
|
|
if (!cat) return;
|
|
dispatch({
|
|
type: "START_EDITING",
|
|
payload: {
|
|
name: cat.name,
|
|
type: cat.type,
|
|
color: cat.color ?? "#4A90A4",
|
|
parent_id: cat.parent_id,
|
|
is_inputable: cat.is_inputable,
|
|
sort_order: cat.sort_order,
|
|
},
|
|
});
|
|
}, [state.categories, state.selectedCategoryId]);
|
|
|
|
const cancelEditing = useCallback(() => {
|
|
dispatch({ type: "CANCEL_EDITING" });
|
|
}, []);
|
|
|
|
const saveCategory = useCallback(
|
|
async (formData: CategoryFormData) => {
|
|
dispatch({ type: "SET_SAVING", payload: true });
|
|
dispatch({ type: "SET_ERROR", payload: null });
|
|
|
|
try {
|
|
if (state.isCreating) {
|
|
const sortOrder = await getNextSortOrder(formData.parent_id);
|
|
const newId = await createCategory({ ...formData, sort_order: sortOrder });
|
|
await loadCategories();
|
|
await selectCategory(newId);
|
|
} else if (state.selectedCategoryId !== null) {
|
|
await updateCategory(state.selectedCategoryId, formData);
|
|
await loadCategories();
|
|
await selectCategory(state.selectedCategoryId);
|
|
}
|
|
dispatch({ type: "SET_SAVING", payload: false });
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
},
|
|
[state.isCreating, state.selectedCategoryId, loadCategories, selectCategory]
|
|
);
|
|
|
|
const deleteCategory = useCallback(
|
|
async (id: number): Promise<{ blocked: boolean; count: number }> => {
|
|
const count = await getCategoryUsageCount(id);
|
|
if (count > 0) {
|
|
return { blocked: true, count };
|
|
}
|
|
// Also check children usage — they'll be promoted to root, not deleted
|
|
const childrenCount = await getChildrenUsageCount(id);
|
|
if (childrenCount > 0) {
|
|
return { blocked: true, count: childrenCount };
|
|
}
|
|
dispatch({ type: "SET_SAVING", payload: true });
|
|
try {
|
|
await deactivateCategory(id);
|
|
dispatch({ type: "SELECT_CATEGORY", payload: null });
|
|
await loadCategories();
|
|
dispatch({ type: "SET_SAVING", payload: false });
|
|
return { blocked: false, count: 0 };
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
return { blocked: false, count: 0 };
|
|
}
|
|
},
|
|
[loadCategories]
|
|
);
|
|
|
|
const reinitializeCategories = useCallback(async () => {
|
|
dispatch({ type: "SET_SAVING", payload: true });
|
|
dispatch({ type: "SET_ERROR", payload: null });
|
|
try {
|
|
await reinitializeCategoriesSvc();
|
|
dispatch({ type: "SELECT_CATEGORY", payload: null });
|
|
await loadCategories();
|
|
dispatch({ type: "SET_SAVING", payload: false });
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
}, [loadCategories]);
|
|
|
|
const moveCategory = useCallback(
|
|
async (categoryId: number, newParentId: number | null, newIndex: number) => {
|
|
// Clone current tree
|
|
const cloneNode = (n: CategoryTreeNode): CategoryTreeNode => ({
|
|
...n,
|
|
children: n.children.map(cloneNode),
|
|
});
|
|
const newTree = state.tree.map(cloneNode);
|
|
|
|
// Recursively find and remove the category from its current position
|
|
function removeFromList(list: CategoryTreeNode[]): CategoryTreeNode | null {
|
|
const idx = list.findIndex((n) => n.id === categoryId);
|
|
if (idx !== -1) {
|
|
return list.splice(idx, 1)[0];
|
|
}
|
|
for (const node of list) {
|
|
const found = removeFromList(node.children);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
const movedNode = removeFromList(newTree);
|
|
|
|
if (!movedNode) return;
|
|
|
|
// Update parent_id
|
|
movedNode.parent_id = newParentId;
|
|
|
|
// Insert at new position
|
|
if (newParentId === null) {
|
|
newTree.splice(newIndex, 0, movedNode);
|
|
} else {
|
|
// Find parent anywhere in the tree
|
|
function findNode(list: CategoryTreeNode[], id: number): CategoryTreeNode | null {
|
|
for (const n of list) {
|
|
if (n.id === id) return n;
|
|
const found = findNode(n.children, id);
|
|
if (found) return found;
|
|
}
|
|
return null;
|
|
}
|
|
const newParent = findNode(newTree, newParentId);
|
|
if (!newParent) return;
|
|
newParent.children.splice(newIndex, 0, movedNode);
|
|
}
|
|
|
|
// Optimistic update
|
|
dispatch({ type: "SET_TREE", payload: newTree });
|
|
|
|
// Compute batch updates for all nodes in the tree (3 levels)
|
|
const updates: Array<{ id: number; sort_order: number; parent_id: number | null }> = [];
|
|
|
|
function collectUpdates(nodes: CategoryTreeNode[], parentId: number | null) {
|
|
nodes.forEach((n, i) => {
|
|
updates.push({ id: n.id, sort_order: i + 1, parent_id: parentId });
|
|
collectUpdates(n.children, n.id);
|
|
});
|
|
}
|
|
collectUpdates(newTree, null);
|
|
|
|
const written = updateCategorySortOrders(updates);
|
|
pendingMoveRef.current = written.catch(() => undefined);
|
|
try {
|
|
await written;
|
|
} catch {
|
|
// Revert on error
|
|
await loadCategories();
|
|
}
|
|
},
|
|
[state.tree, loadCategories]
|
|
);
|
|
|
|
/**
|
|
* Opens the « Vérifier la hiérarchie » window and runs the diagnostic (#393).
|
|
* The window names the orphans and offers their parents from
|
|
* `state.categories`, which a drag-and-drop leaves with what the tree shows
|
|
* only (`SET_TREE`), without the categories no root reaches: the categories
|
|
* are read again first, once the drag-and-drop's writes are over (one
|
|
* `UPDATE` per category: read halfway, the tree would show half the move).
|
|
* Without the page's loading state, so a form open behind the window stays
|
|
* as it is; a load started meanwhile wins.
|
|
*/
|
|
const checkHierarchy = useCallback(async () => {
|
|
dispatch({ type: "OPEN_HIERARCHY_CHECK" });
|
|
try {
|
|
await pendingMoveRef.current;
|
|
const fetchId = fetchIdRef.current;
|
|
const rows = await getAllCategoriesWithCounts();
|
|
if (fetchId === fetchIdRef.current) {
|
|
const flat = rows.map((r) => ({ ...r, children: [] as CategoryTreeNode[] }));
|
|
dispatch({ type: "SET_CATEGORIES", payload: { flat, tree: buildTree(flat) } });
|
|
}
|
|
const orphans = await findOrphanCategories();
|
|
dispatch({ type: "SET_ORPHANS", payload: orphans });
|
|
} catch (e) {
|
|
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
}, []);
|
|
|
|
/**
|
|
* Reattaches an orphan under `parentId`, or to the root with `null` (#393).
|
|
* On success: one Journal line (counts, no name), then the orphan list and
|
|
* the tree reload; the tree reloads even if the new check fails, since the
|
|
* write is committed. A refusal shows in the window, by its translated code.
|
|
*/
|
|
const reattachOrphan = useCallback(
|
|
async (orphan: OrphanCategory, parentId: number | null) => {
|
|
dispatch({ type: "START_REATTACH" });
|
|
try {
|
|
const restored = await reattachCategory(orphan.id, parentId);
|
|
logInfo(reattachLogLine(orphan.reason, restored));
|
|
try {
|
|
const orphans = await findOrphanCategories();
|
|
dispatch({ type: "SET_ORPHANS", payload: orphans });
|
|
} finally {
|
|
await loadCategories();
|
|
}
|
|
dispatch({ type: "SET_SAVING", payload: false });
|
|
} catch (e) {
|
|
dispatch({ type: "SET_HIERARCHY_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
},
|
|
[loadCategories]
|
|
);
|
|
|
|
const closeHierarchyCheck = useCallback(() => {
|
|
dispatch({ type: "CLOSE_HIERARCHY_CHECK" });
|
|
}, []);
|
|
|
|
const loadKeywords = useCallback(async (categoryId: number) => {
|
|
try {
|
|
const kws = await getKeywordsByCategoryId(categoryId);
|
|
dispatch({ type: "SET_KEYWORDS", payload: kws });
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
}, []);
|
|
|
|
const addKeyword = useCallback(
|
|
async (keyword: string, priority: number) => {
|
|
if (state.selectedCategoryId === null) return;
|
|
try {
|
|
await createKeyword(state.selectedCategoryId, keyword, priority);
|
|
await loadKeywords(state.selectedCategoryId);
|
|
await loadCategories();
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
},
|
|
[state.selectedCategoryId, loadKeywords, loadCategories]
|
|
);
|
|
|
|
const editKeyword = useCallback(
|
|
async (id: number, keyword: string, priority: number) => {
|
|
try {
|
|
await updateKeyword(id, keyword, priority);
|
|
if (state.selectedCategoryId !== null) {
|
|
await loadKeywords(state.selectedCategoryId);
|
|
}
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
},
|
|
[state.selectedCategoryId, loadKeywords]
|
|
);
|
|
|
|
const removeKeyword = useCallback(
|
|
async (id: number) => {
|
|
try {
|
|
await deactivateKeyword(id);
|
|
if (state.selectedCategoryId !== null) {
|
|
await loadKeywords(state.selectedCategoryId);
|
|
await loadCategories();
|
|
}
|
|
} catch (e) {
|
|
dispatch({ type: "SET_ERROR", payload: categoriesErrorOf(e) });
|
|
}
|
|
},
|
|
[state.selectedCategoryId, loadKeywords, loadCategories]
|
|
);
|
|
|
|
return {
|
|
state,
|
|
selectCategory,
|
|
startCreating,
|
|
startEditing,
|
|
cancelEditing,
|
|
saveCategory,
|
|
deleteCategory,
|
|
addKeyword,
|
|
editKeyword,
|
|
removeKeyword,
|
|
reinitializeCategories,
|
|
moveCategory,
|
|
checkHierarchy,
|
|
reattachOrphan,
|
|
closeHierarchyCheck,
|
|
};
|
|
}
|