feat: implement categories page with hierarchical tree, CRUD, and keyword management

Two-panel layout with collapsible category tree (left) and detail/edit panel (right).
Supports create/edit/delete categories, color swatches, parent-child hierarchy,
and keyword management with inline editing and priority badges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-08 23:15:31 +00:00
parent 2b9fc49b51
commit a2beb583d1
10 changed files with 1084 additions and 5 deletions

View file

@ -0,0 +1,164 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Pencil } from "lucide-react";
import type { CategoryTreeNode, CategoryFormData, Keyword } from "../../shared/types";
import CategoryForm from "./CategoryForm";
import KeywordList from "./KeywordList";
interface Props {
categories: CategoryTreeNode[];
selectedCategory: CategoryTreeNode | null;
keywords: Keyword[];
editingCategory: CategoryFormData | null;
isCreating: boolean;
isSaving: boolean;
onStartEditing: () => void;
onCancelEditing: () => void;
onSave: (data: CategoryFormData) => void;
onDelete: (id: number) => Promise<{ blocked: boolean; count: number }>;
onAddKeyword: (keyword: string, priority: number) => void;
onUpdateKeyword: (id: number, keyword: string, priority: number) => void;
onRemoveKeyword: (id: number) => void;
}
export default function CategoryDetailPanel({
categories,
selectedCategory,
keywords,
editingCategory,
isCreating,
isSaving,
onStartEditing,
onCancelEditing,
onSave,
onDelete,
onAddKeyword,
onUpdateKeyword,
onRemoveKeyword,
}: Props) {
const { t } = useTranslation();
const [deleteError, setDeleteError] = useState<string | null>(null);
const handleDelete = async () => {
if (!selectedCategory) return;
if (!confirm(t("categories.deleteConfirm"))) return;
setDeleteError(null);
const result = await onDelete(selectedCategory.id);
if (result.blocked) {
setDeleteError(t("categories.deleteBlocked", { count: result.count }));
}
};
// No selection and not creating
if (!selectedCategory && !isCreating) {
return (
<div className="flex-1 flex items-center justify-center bg-[var(--card)] rounded-xl border border-[var(--border)] p-8">
<p className="text-[var(--muted-foreground)]">{t("categories.selectCategory")}</p>
</div>
);
}
// Creating new
if (isCreating && editingCategory) {
return (
<div className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">{t("categories.addCategory")}</h2>
<CategoryForm
initialData={editingCategory}
categories={categories}
isCreating
isSaving={isSaving}
onSave={onSave}
onCancel={onCancelEditing}
/>
</div>
);
}
if (!selectedCategory) return null;
// Editing existing
if (editingCategory) {
return (
<div className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">{t("categories.editCategory")}</h2>
{deleteError && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{deleteError}
</div>
)}
<CategoryForm
initialData={editingCategory}
categories={categories}
isCreating={false}
isSaving={isSaving}
onSave={onSave}
onCancel={onCancelEditing}
onDelete={handleDelete}
/>
<div className="mt-6 border-t border-[var(--border)] pt-4">
<KeywordList
keywords={keywords}
onAdd={onAddKeyword}
onUpdate={onUpdateKeyword}
onRemove={onRemoveKeyword}
/>
</div>
</div>
);
}
// Read-only view
return (
<div className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<span
className="w-4 h-4 rounded-full"
style={{ backgroundColor: selectedCategory.color ?? "#9ca3af" }}
/>
<h2 className="text-lg font-semibold">{selectedCategory.name}</h2>
</div>
<button
onClick={onStartEditing}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
<Pencil size={14} />
{t("common.edit")}
</button>
</div>
<div className="grid grid-cols-2 gap-4 mb-6 text-sm">
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.type")}</span>
<p className="font-medium capitalize">{t(`categories.${selectedCategory.type}`)}</p>
</div>
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.sortOrder")}</span>
<p className="font-medium">{selectedCategory.sort_order}</p>
</div>
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.parent")}</span>
<p className="font-medium">
{selectedCategory.parent_id
? categories.find((c) => c.id === selectedCategory.parent_id)?.name ?? "—"
: t("categories.noParent")}
</p>
</div>
<div>
<span className="text-[var(--muted-foreground)]">{t("categories.keywordCount")}</span>
<p className="font-medium">{selectedCategory.keyword_count}</p>
</div>
</div>
<div className="border-t border-[var(--border)] pt-4">
<KeywordList
keywords={keywords}
onAdd={onAddKeyword}
onUpdate={onUpdateKeyword}
onRemove={onRemoveKeyword}
/>
</div>
</div>
);
}

View file

@ -0,0 +1,160 @@
import { useState, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { Trash2 } from "lucide-react";
import type { CategoryFormData, CategoryTreeNode } from "../../shared/types";
const PRESET_COLORS = [
"#4A90A4",
"#C17767",
"#22c55e",
"#ef4444",
"#a855f7",
"#f59e0b",
"#6366f1",
"#64748b",
"#9ca3af",
];
interface Props {
initialData: CategoryFormData;
categories: CategoryTreeNode[];
isCreating: boolean;
isSaving: boolean;
onSave: (data: CategoryFormData) => void;
onCancel: () => void;
onDelete?: () => void;
}
export default function CategoryForm({
initialData,
categories,
isCreating,
isSaving,
onSave,
onCancel,
onDelete,
}: Props) {
const { t } = useTranslation();
const [form, setForm] = useState<CategoryFormData>(initialData);
useEffect(() => {
setForm(initialData);
}, [initialData]);
const parentOptions = categories.filter(
(c) => c.parent_id === null
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!form.name.trim()) return;
onSave({ ...form, name: form.name.trim() });
};
return (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1">{t("categories.name")}</label>
<input
type="text"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("categories.type")}</label>
<select
value={form.type}
onChange={(e) => setForm({ ...form, type: e.target.value as CategoryFormData["type"] })}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="expense">{t("categories.expense")}</option>
<option value="income">{t("categories.income")}</option>
<option value="transfer">{t("categories.transfer")}</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("categories.color")}</label>
<div className="flex items-center gap-2 flex-wrap">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setForm({ ...form, color: c })}
className={`w-7 h-7 rounded-full border-2 transition-transform ${
form.color === c ? "border-[var(--foreground)] scale-110" : "border-transparent"
}`}
style={{ backgroundColor: c }}
/>
))}
<input
type="color"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
className="w-7 h-7 rounded-full border border-[var(--border)] cursor-pointer"
title={t("categories.customColor")}
/>
</div>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("categories.parent")}</label>
<select
value={form.parent_id ?? ""}
onChange={(e) =>
setForm({ ...form, parent_id: e.target.value ? Number(e.target.value) : null })
}
className="w-full px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
<option value="">{t("categories.noParent")}</option>
{parentOptions.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("categories.sortOrder")}</label>
<input
type="number"
value={form.sort_order}
onChange={(e) => setForm({ ...form, sort_order: Number(e.target.value) })}
className="w-24 px-3 py-2 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
</div>
<div className="flex items-center gap-2 pt-2">
<button
type="submit"
disabled={isSaving || !form.name.trim()}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("common.save")}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
{!isCreating && onDelete && (
<button
type="button"
onClick={onDelete}
className="ml-auto px-3 py-2 rounded-lg text-[var(--negative)] hover:bg-[var(--negative)]/10 text-sm flex items-center gap-1"
>
<Trash2 size={14} />
{t("common.delete")}
</button>
)}
</div>
</form>
);
}

View file

@ -0,0 +1,128 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { ChevronRight, ChevronDown } from "lucide-react";
import type { CategoryTreeNode } from "../../shared/types";
interface Props {
tree: CategoryTreeNode[];
selectedId: number | null;
onSelect: (id: number) => void;
}
function TypeBadge({ type }: { type: string }) {
const { t } = useTranslation();
const colors: Record<string, string> = {
expense: "bg-[var(--negative)]/15 text-[var(--negative)]",
income: "bg-[var(--positive)]/15 text-[var(--positive)]",
transfer: "bg-[var(--primary)]/15 text-[var(--primary)]",
};
return (
<span className={`text-[10px] px-1.5 py-0.5 rounded-full font-medium ${colors[type] ?? ""}`}>
{t(`categories.${type}`)}
</span>
);
}
function TreeRow({
node,
depth,
selectedId,
onSelect,
expanded,
onToggle,
hasChildren,
}: {
node: CategoryTreeNode;
depth: number;
selectedId: number | null;
onSelect: (id: number) => void;
expanded: boolean;
onToggle: () => void;
hasChildren: boolean;
}) {
const isSelected = node.id === selectedId;
return (
<button
onClick={() => onSelect(node.id)}
className={`w-full flex items-center gap-2 px-3 py-2 text-sm text-left rounded-lg transition-colors
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}`}
style={{ paddingLeft: `${depth * 20 + 12}px` }}
>
{hasChildren ? (
<span
onClick={(e) => {
e.stopPropagation();
onToggle();
}}
className="w-4 h-4 flex items-center justify-center cursor-pointer text-[var(--muted-foreground)]"
>
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
) : (
<span className="w-4" />
)}
<span
className="w-3 h-3 rounded-full flex-shrink-0"
style={{ backgroundColor: node.color ?? "#9ca3af" }}
/>
<span className="flex-1 truncate">{node.name}</span>
<TypeBadge type={node.type} />
{node.keyword_count > 0 && (
<span className="text-[11px] text-[var(--muted-foreground)]">
{node.keyword_count}
</span>
)}
</button>
);
}
export default function CategoryTree({ tree, selectedId, onSelect }: Props) {
const [expanded, setExpanded] = useState<Set<number>>(() => {
const ids = new Set<number>();
for (const node of tree) {
if (node.children.length > 0) ids.add(node.id);
}
return ids;
});
const toggle = (id: number) => {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
return (
<div className="flex flex-col gap-0.5">
{tree.map((parent) => (
<div key={parent.id}>
<TreeRow
node={parent}
depth={0}
selectedId={selectedId}
onSelect={onSelect}
expanded={expanded.has(parent.id)}
onToggle={() => toggle(parent.id)}
hasChildren={parent.children.length > 0}
/>
{expanded.has(parent.id) &&
parent.children.map((child) => (
<TreeRow
key={child.id}
node={child}
depth={1}
selectedId={selectedId}
onSelect={onSelect}
expanded={false}
onToggle={() => {}}
hasChildren={false}
/>
))}
</div>
))}
</div>
);
}

View file

@ -0,0 +1,131 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { X, Plus } from "lucide-react";
import type { Keyword } from "../../shared/types";
interface Props {
keywords: Keyword[];
onAdd: (keyword: string, priority: number) => void;
onUpdate: (id: number, keyword: string, priority: number) => void;
onRemove: (id: number) => void;
}
export default function KeywordList({ keywords, onAdd, onUpdate, onRemove }: Props) {
const { t } = useTranslation();
const [newKeyword, setNewKeyword] = useState("");
const [newPriority, setNewPriority] = useState(0);
const [editingId, setEditingId] = useState<number | null>(null);
const [editText, setEditText] = useState("");
const [editPriority, setEditPriority] = useState(0);
const handleAdd = () => {
if (!newKeyword.trim()) return;
onAdd(newKeyword.trim(), newPriority);
setNewKeyword("");
setNewPriority(0);
};
const startEdit = (kw: Keyword) => {
setEditingId(kw.id);
setEditText(kw.keyword);
setEditPriority(kw.priority);
};
const saveEdit = () => {
if (editingId === null || !editText.trim()) return;
onUpdate(editingId, editText.trim(), editPriority);
setEditingId(null);
};
const cancelEdit = () => {
setEditingId(null);
};
return (
<div className="flex flex-col gap-3">
<h3 className="text-sm font-medium">{t("categories.keywords")}</h3>
<div className="flex items-center gap-2">
<input
type="text"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={t("categories.keywordText")}
className="flex-1 px-3 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
<input
type="number"
value={newPriority}
onChange={(e) => setNewPriority(Number(e.target.value))}
className="w-16 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-center focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
title={t("categories.priority")}
/>
<button
onClick={handleAdd}
disabled={!newKeyword.trim()}
className="p-1.5 rounded-lg bg-[var(--primary)] text-white hover:opacity-90 disabled:opacity-50"
>
<Plus size={16} />
</button>
</div>
<div className="flex flex-wrap gap-2">
{keywords.map((kw) =>
editingId === kw.id ? (
<div key={kw.id} className="flex items-center gap-1 bg-[var(--muted)] rounded-full px-2 py-1">
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveEdit();
if (e.key === "Escape") cancelEdit();
}}
className="w-24 px-1 py-0 rounded border border-[var(--border)] bg-[var(--card)] text-xs focus:outline-none"
autoFocus
/>
<input
type="number"
value={editPriority}
onChange={(e) => setEditPriority(Number(e.target.value))}
className="w-10 px-1 py-0 rounded border border-[var(--border)] bg-[var(--card)] text-xs text-center focus:outline-none"
/>
<button onClick={saveEdit} className="text-[var(--positive)] text-xs font-medium px-1">
OK
</button>
<button onClick={cancelEdit} className="text-[var(--muted-foreground)] text-xs px-1">
ESC
</button>
</div>
) : (
<span
key={kw.id}
onClick={() => startEdit(kw)}
className="inline-flex items-center gap-1 bg-[var(--muted)] rounded-full px-3 py-1 text-sm cursor-pointer hover:bg-[var(--muted)]/80"
>
{kw.keyword}
{kw.priority > 0 && (
<span className="text-[10px] bg-[var(--primary)]/15 text-[var(--primary)] px-1 rounded-full font-medium">
{kw.priority}
</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
onRemove(kw.id);
}}
className="ml-0.5 text-[var(--muted-foreground)] hover:text-[var(--negative)]"
>
<X size={12} />
</button>
</span>
)
)}
{keywords.length === 0 && (
<p className="text-sm text-[var(--muted-foreground)]">{t("common.noResults")}</p>
)}
</div>
</div>
);
}

272
src/hooks/useCategories.ts Normal file
View file

@ -0,0 +1,272 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type {
CategoryTreeNode,
CategoryFormData,
Keyword,
} from "../shared/types";
import {
getAllCategoriesWithCounts,
createCategory,
updateCategory,
deactivateCategory,
getCategoryUsageCount,
getKeywordsByCategoryId,
createKeyword,
updateKeyword,
deactivateKeyword,
} from "../services/categoryService";
interface CategoriesState {
categories: CategoryTreeNode[];
tree: CategoryTreeNode[];
selectedCategoryId: number | null;
keywords: Keyword[];
isLoading: boolean;
isSaving: boolean;
error: string | null;
editingCategory: CategoryFormData | null;
isCreating: boolean;
}
type CategoriesAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_CATEGORIES"; payload: { flat: CategoryTreeNode[]; tree: CategoryTreeNode[] } }
| { type: "SELECT_CATEGORY"; payload: number | null }
| { type: "SET_KEYWORDS"; payload: Keyword[] }
| { type: "START_CREATING" }
| { type: "START_EDITING"; payload: CategoryFormData }
| { type: "CANCEL_EDITING" };
const initialState: CategoriesState = {
categories: [],
tree: [],
selectedCategoryId: null,
keywords: [],
isLoading: false,
isSaving: false,
error: null,
editingCategory: null,
isCreating: false,
};
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 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 "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, sort_order: 0 },
keywords: [],
};
case "START_EDITING":
return { ...state, isCreating: false, editingCategory: action.payload };
case "CANCEL_EDITING":
return { ...state, editingCategory: null, isCreating: false };
default:
return state;
}
}
export function useCategories() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const loadCategories = useCallback(async () => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
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: e instanceof Error ? e.message : String(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: e instanceof Error ? e.message : String(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,
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 newId = await createCategory(formData);
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: e instanceof Error ? e.message : String(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 };
}
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: e instanceof Error ? e.message : String(e) });
return { blocked: false, count: 0 };
}
},
[loadCategories]
);
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: e instanceof Error ? e.message : String(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: e instanceof Error ? e.message : String(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: e instanceof Error ? e.message : String(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: e instanceof Error ? e.message : String(e) });
}
},
[state.selectedCategoryId, loadKeywords, loadCategories]
);
return {
state,
selectCategory,
startCreating,
startEditing,
cancelEditing,
saveCategory,
deleteCategory,
addKeyword,
editKeyword,
removeKeyword,
};
}

View file

@ -166,7 +166,19 @@
"expense": "Expense",
"income": "Income",
"transfer": "Transfer",
"keywords": "Keywords"
"keywords": "Keywords",
"addCategory": "Add Category",
"editCategory": "Edit Category",
"deleteCategory": "Delete Category",
"deleteConfirm": "Are you sure you want to delete this category? Its children will also be deleted.",
"deleteBlocked": "Cannot delete: this category is used by {{count}} transaction(s).",
"noParent": "No parent (top-level)",
"sortOrder": "Sort Order",
"selectCategory": "Select a category to view details",
"keywordCount": "Keywords",
"keywordText": "Keyword...",
"priority": "Priority",
"customColor": "Custom color"
},
"adjustments": {
"title": "Adjustments",

View file

@ -166,7 +166,19 @@
"expense": "Dépense",
"income": "Revenu",
"transfer": "Transfert",
"keywords": "Mots-clés"
"keywords": "Mots-clés",
"addCategory": "Ajouter une catégorie",
"editCategory": "Modifier la catégorie",
"deleteCategory": "Supprimer la catégorie",
"deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette catégorie ? Ses sous-catégories seront également supprimées.",
"deleteBlocked": "Impossible de supprimer : cette catégorie est utilisée par {{count}} transaction(s).",
"noParent": "Aucun parent (niveau supérieur)",
"sortOrder": "Ordre de tri",
"selectCategory": "Sélectionnez une catégorie pour voir les détails",
"keywordCount": "Mots-clés",
"keywordText": "Mot-clé...",
"priority": "Priorité",
"customColor": "Couleur personnalisée"
},
"adjustments": {
"title": "Ajustements",

View file

@ -1,14 +1,76 @@
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { useCategories } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree";
import CategoryDetailPanel from "../components/categories/CategoryDetailPanel";
export default function CategoriesPage() {
const { t } = useTranslation();
const {
state,
selectCategory,
startCreating,
startEditing,
cancelEditing,
saveCategory,
deleteCategory,
addKeyword,
editKeyword,
removeKeyword,
} = useCategories();
const selectedCategory =
state.selectedCategoryId !== null
? state.categories.find((c) => c.id === state.selectedCategoryId) ?? null
: null;
return (
<div>
<h1 className="text-2xl font-bold mb-6">{t("categories.title")}</h1>
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("common.noResults")}</p>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
<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>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{state.error}
</div>
)}
{state.isLoading ? (
<p className="text-[var(--muted-foreground)]">{t("common.loading")}</p>
) : (
<div className="flex gap-6" style={{ minHeight: "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}
/>
</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>
)}
</div>
);
}

View file

@ -0,0 +1,115 @@
import { getDb } from "./db";
import type { Keyword } from "../shared/types";
interface CategoryRow {
id: number;
name: string;
parent_id: number | null;
color: string | null;
icon: string | null;
type: "expense" | "income" | "transfer";
is_active: boolean;
sort_order: number;
keyword_count: number;
}
export async function getAllCategoriesWithCounts(): Promise<CategoryRow[]> {
const db = await getDb();
return db.select<CategoryRow[]>(
`SELECT c.*, COUNT(k.id) AS keyword_count
FROM categories c
LEFT JOIN keywords k ON k.category_id = c.id AND k.is_active = 1
WHERE c.is_active = 1
GROUP BY c.id
ORDER BY c.sort_order, c.name`
);
}
export async function createCategory(data: {
name: string;
type: string;
color: string;
parent_id: number | null;
sort_order: number;
}): Promise<number> {
const db = await getDb();
const result = await db.execute(
`INSERT INTO categories (name, type, color, parent_id, sort_order) VALUES ($1, $2, $3, $4, $5)`,
[data.name, data.type, data.color, data.parent_id, data.sort_order]
);
return result.lastInsertId as number;
}
export async function updateCategory(
id: number,
data: {
name: string;
type: string;
color: string;
parent_id: number | null;
sort_order: number;
}
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, sort_order = $5 WHERE id = $6`,
[data.name, data.type, data.color, data.parent_id, data.sort_order, id]
);
}
export async function deactivateCategory(id: number): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1 OR parent_id = $1`,
[id]
);
}
export async function getCategoryUsageCount(id: number): Promise<number> {
const db = await getDb();
const rows = await db.select<Array<{ cnt: number }>>(
`SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`,
[id]
);
return rows[0]?.cnt ?? 0;
}
export async function getKeywordsByCategoryId(
categoryId: number
): Promise<Keyword[]> {
const db = await getDb();
return db.select<Keyword[]>(
`SELECT * FROM keywords WHERE category_id = $1 AND is_active = 1 ORDER BY priority DESC, keyword`,
[categoryId]
);
}
export async function createKeyword(
categoryId: number,
keyword: string,
priority: number
): Promise<number> {
const db = await getDb();
const result = await db.execute(
`INSERT INTO keywords (keyword, category_id, priority) VALUES ($1, $2, $3)`,
[keyword, categoryId, priority]
);
return result.lastInsertId as number;
}
export async function updateKeyword(
id: number,
keyword: string,
priority: number
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE keywords SET keyword = $1, priority = $2 WHERE id = $3`,
[keyword, priority, id]
);
}
export async function deactivateKeyword(id: number): Promise<void> {
const db = await getDb();
await db.execute(`UPDATE keywords SET is_active = 0 WHERE id = $1`, [id]);
}

View file

@ -211,6 +211,29 @@ export type ImportWizardStep =
| "importing"
| "report";
// --- Category Page Types ---
export interface CategoryTreeNode {
id: number;
name: string;
parent_id: number | null;
color: string | null;
icon: string | null;
type: "expense" | "income" | "transfer";
is_active: boolean;
sort_order: number;
keyword_count: number;
children: CategoryTreeNode[];
}
export interface CategoryFormData {
name: string;
type: "expense" | "income" | "transfer";
color: string;
parent_id: number | null;
sort_order: number;
}
// --- Transaction Page Types ---
export interface TransactionRow {