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",
|
"expense": "Expense",
|
||||||
"income": "Income",
|
"income": "Income",
|
||||||
"transfer": "Transfer",
|
"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": {
|
"adjustments": {
|
||||||
"title": "Adjustments",
|
"title": "Adjustments",
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,19 @@
|
||||||
"expense": "Dépense",
|
"expense": "Dépense",
|
||||||
"income": "Revenu",
|
"income": "Revenu",
|
||||||
"transfer": "Transfert",
|
"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": {
|
"adjustments": {
|
||||||
"title": "Ajustements",
|
"title": "Ajustements",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,76 @@
|
||||||
import { useTranslation } from "react-i18next";
|
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() {
|
export default function CategoriesPage() {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-2xl font-bold mb-6">{t("categories.title")}</h1>
|
<div className="flex items-center justify-between mb-6">
|
||||||
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
|
<h1 className="text-2xl font-bold">{t("categories.title")}</h1>
|
||||||
<p>{t("common.noResults")}</p>
|
<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">
|
||||||
|
{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>
|
</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"
|
| "importing"
|
||||||
| "report";
|
| "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 ---
|
// --- Transaction Page Types ---
|
||||||
|
|
||||||
export interface TransactionRow {
|
export interface TransactionRow {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue