updateCategory checks a new parent before writing: the category itself, one of its descendants or a category caught in a cycle (cycle), a fourth level counting its sub-tree (too_deep), a parent holding transactions of its own (parent_has_transactions). The refusal is a CategoryHierarchyError that the Categories page shows by the translation of its code. The reads and the writes, the new parent's is_inputable included, run in one transaction through its handle; an unchanged parent is not checked, so a category stored before these rules can still be renamed. getCategoryDepth keeps the ids it has seen and ends on a cycle. A "Check hierarchy" button, between "All keywords" and "Re-initialize", opens a window that lists the orphan categories (caught in a parent loop, parent deleted, parent gone), one per group, with a new parent for each: the root by default, or a parent of the category form's list. reattachCategory applies the same rules; after it, the list and the tree reload and the Journal gets one line with the reason and the number of categories put back, no name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
7.6 KiB
TypeScript
199 lines
7.6 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useLocation } from "react-router-dom";
|
|
import { Plus, RotateCcw, List, AlertTriangle, ListTree } from "lucide-react";
|
|
import { PageHelp } from "../components/shared/PageHelp";
|
|
import { useCategories, categoriesErrorText } from "../hooks/useCategories";
|
|
import CategoryTree from "../components/categories/CategoryTree";
|
|
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
|
|
import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
|
|
import HierarchyCheckModal from "../components/categories/HierarchyCheckModal";
|
|
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
|
|
import { useOnboarding } from "../contexts/OnboardingContext";
|
|
import {
|
|
categoriesCardModel,
|
|
onboardingTargetAttributes,
|
|
showsStepCard,
|
|
} from "../utils/onboardingPath";
|
|
|
|
/** The target key of "All keywords" while the Categories step card points at it (#432). */
|
|
export const CATEGORIES_ALL_KEYWORDS_TARGET = "categories-all-keywords";
|
|
|
|
export default function CategoriesPage() {
|
|
const { t } = useTranslation();
|
|
const [showAllKeywords, setShowAllKeywords] = useState(false);
|
|
const [showReinitConfirm, setShowReinitConfirm] = useState(false);
|
|
const {
|
|
state,
|
|
selectCategory,
|
|
startCreating,
|
|
startEditing,
|
|
cancelEditing,
|
|
saveCategory,
|
|
deleteCategory,
|
|
addKeyword,
|
|
editKeyword,
|
|
removeKeyword,
|
|
reinitializeCategories,
|
|
moveCategory,
|
|
checkHierarchy,
|
|
reattachOrphan,
|
|
closeHierarchyCheck,
|
|
} = useCategories();
|
|
|
|
// The getting-started card (#432) points at "All keywords" while it is on
|
|
// screen, never at "Reset" (it deletes the keywords and uncategorizes every
|
|
// transaction).
|
|
const location = useLocation();
|
|
const onboarding = useOnboarding();
|
|
const cardModel = categoriesCardModel(onboarding.checklist);
|
|
const target = showsStepCard(onboarding.mode, onboarding.cardPage, location.pathname)
|
|
? cardModel.target
|
|
: null;
|
|
|
|
const handleReinitialize = async () => {
|
|
setShowReinitConfirm(false);
|
|
await reinitializeCategories();
|
|
};
|
|
|
|
const selectedCategory =
|
|
state.selectedCategoryId !== null
|
|
? state.categories.find((c) => c.id === state.selectedCategoryId) ?? null
|
|
: null;
|
|
|
|
return (
|
|
<div>
|
|
<OnboardingStepCard model={cardModel} />
|
|
|
|
<div className="relative flex items-center justify-between mb-6">
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
|
|
<PageHelp helpKey="categories" />
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => setShowAllKeywords((v) => !v)}
|
|
{...onboardingTargetAttributes(CATEGORIES_ALL_KEYWORDS_TARGET, target === "allKeywords")}
|
|
className={`flex items-center gap-2 px-3 py-2 rounded-lg border text-sm font-medium transition-colors ${
|
|
showAllKeywords
|
|
? "border-[var(--primary)] bg-[var(--primary)]/10 text-[var(--primary)]"
|
|
: "border-[var(--border)] hover:bg-[var(--muted)]"
|
|
}`}
|
|
>
|
|
<List size={16} />
|
|
{t("categories.allKeywords")}
|
|
</button>
|
|
<button
|
|
onClick={checkHierarchy}
|
|
disabled={state.isSaving}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
|
>
|
|
<ListTree size={16} />
|
|
{t("categories.hierarchy.check")}
|
|
</button>
|
|
<button
|
|
onClick={() => setShowReinitConfirm(true)}
|
|
disabled={state.isSaving}
|
|
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
|
|
>
|
|
<RotateCcw size={16} />
|
|
{t("categories.reinitialize")}
|
|
</button>
|
|
<button
|
|
onClick={startCreating}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
|
|
>
|
|
<Plus size={16} />
|
|
{t("categories.addCategory")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{state.error && (
|
|
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
|
|
{categoriesErrorText(state.error, t)}
|
|
</div>
|
|
)}
|
|
|
|
{state.isLoading ? (
|
|
<p className="text-[var(--muted-foreground)]">{t("common.loading")}</p>
|
|
) : showAllKeywords ? (
|
|
<AllKeywordsPanel
|
|
onSelectCategory={(id) => {
|
|
setShowAllKeywords(false);
|
|
selectCategory(id);
|
|
}}
|
|
onRemove={removeKeyword}
|
|
/>
|
|
) : (
|
|
<div className="flex gap-6" style={{ height: "calc(100vh - 180px)" }}>
|
|
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
|
|
<CategoryTree
|
|
tree={state.tree}
|
|
selectedId={state.selectedCategoryId}
|
|
onSelect={selectCategory}
|
|
onMoveCategory={moveCategory}
|
|
/>
|
|
</div>
|
|
<CategoryDetailPanel
|
|
categories={state.categories}
|
|
selectedCategory={selectedCategory}
|
|
keywords={state.keywords}
|
|
editingCategory={state.editingCategory}
|
|
isCreating={state.isCreating}
|
|
isSaving={state.isSaving}
|
|
onStartEditing={startEditing}
|
|
onCancelEditing={cancelEditing}
|
|
onSave={saveCategory}
|
|
onDelete={deleteCategory}
|
|
onAddKeyword={addKeyword}
|
|
onUpdateKeyword={editKeyword}
|
|
onRemoveKeyword={removeKeyword}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{state.hierarchyCheck && (
|
|
<HierarchyCheckModal
|
|
orphans={state.hierarchyCheck.orphans}
|
|
error={state.hierarchyCheck.error ? categoriesErrorText(state.hierarchyCheck.error, t) : null}
|
|
categories={state.categories}
|
|
isSaving={state.isSaving}
|
|
onReattach={reattachOrphan}
|
|
onClose={closeHierarchyCheck}
|
|
/>
|
|
)}
|
|
|
|
{/* Reinitialize confirmation modal */}
|
|
{showReinitConfirm && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
|
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<div className="p-2 rounded-full bg-[var(--negative)]/10">
|
|
<AlertTriangle size={20} className="text-[var(--negative)]" />
|
|
</div>
|
|
<h2 className="text-lg font-semibold">{t("categories.reinitialize")}</h2>
|
|
</div>
|
|
<p className="text-sm text-[var(--muted-foreground)] mb-6">
|
|
{t("categories.reinitializeConfirm")}
|
|
</p>
|
|
<div className="flex justify-end gap-3">
|
|
<button
|
|
onClick={() => setShowReinitConfirm(false)}
|
|
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
|
|
>
|
|
{t("common.cancel")}
|
|
</button>
|
|
<button
|
|
onClick={handleReinitialize}
|
|
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
|
|
>
|
|
{t("common.confirm")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|