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:
parent
2b9fc49b51
commit
a2beb583d1
10 changed files with 1084 additions and 5 deletions
164
src/components/categories/CategoryDetailPanel.tsx
Normal file
164
src/components/categories/CategoryDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
160
src/components/categories/CategoryForm.tsx
Normal file
160
src/components/categories/CategoryForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
128
src/components/categories/CategoryTree.tsx
Normal file
128
src/components/categories/CategoryTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
src/components/categories/KeywordList.tsx
Normal file
131
src/components/categories/KeywordList.tsx
Normal 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
272
src/hooks/useCategories.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
115
src/services/categoryService.ts
Normal file
115
src/services/categoryService.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue