diff --git a/src/components/categories/CategoryDetailPanel.tsx b/src/components/categories/CategoryDetailPanel.tsx new file mode 100644 index 0000000..8a30826 --- /dev/null +++ b/src/components/categories/CategoryDetailPanel.tsx @@ -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(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 ( +
+

{t("categories.selectCategory")}

+
+ ); + } + + // Creating new + if (isCreating && editingCategory) { + return ( +
+

{t("categories.addCategory")}

+ +
+ ); + } + + if (!selectedCategory) return null; + + // Editing existing + if (editingCategory) { + return ( +
+

{t("categories.editCategory")}

+ {deleteError && ( +
+ {deleteError} +
+ )} + +
+ +
+
+ ); + } + + // Read-only view + return ( +
+
+
+ +

{selectedCategory.name}

+
+ +
+ +
+
+ {t("categories.type")} +

{t(`categories.${selectedCategory.type}`)}

+
+
+ {t("categories.sortOrder")} +

{selectedCategory.sort_order}

+
+
+ {t("categories.parent")} +

+ {selectedCategory.parent_id + ? categories.find((c) => c.id === selectedCategory.parent_id)?.name ?? "—" + : t("categories.noParent")} +

+
+
+ {t("categories.keywordCount")} +

{selectedCategory.keyword_count}

+
+
+ +
+ +
+
+ ); +} diff --git a/src/components/categories/CategoryForm.tsx b/src/components/categories/CategoryForm.tsx new file mode 100644 index 0000000..133c43f --- /dev/null +++ b/src/components/categories/CategoryForm.tsx @@ -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(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 ( +
+
+ + 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 + /> +
+ +
+ + +
+ +
+ +
+ {PRESET_COLORS.map((c) => ( +
+
+ +
+ + +
+ +
+ + 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)]" + /> +
+ +
+ + + {!isCreating && onDelete && ( + + )} +
+
+ ); +} diff --git a/src/components/categories/CategoryTree.tsx b/src/components/categories/CategoryTree.tsx new file mode 100644 index 0000000..1a06582 --- /dev/null +++ b/src/components/categories/CategoryTree.tsx @@ -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 = { + 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 ( + + {t(`categories.${type}`)} + + ); +} + +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 ( + + ); +} + +export default function CategoryTree({ tree, selectedId, onSelect }: Props) { + const [expanded, setExpanded] = useState>(() => { + const ids = new Set(); + 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 ( +
+ {tree.map((parent) => ( +
+ toggle(parent.id)} + hasChildren={parent.children.length > 0} + /> + {expanded.has(parent.id) && + parent.children.map((child) => ( + {}} + hasChildren={false} + /> + ))} +
+ ))} +
+ ); +} diff --git a/src/components/categories/KeywordList.tsx b/src/components/categories/KeywordList.tsx new file mode 100644 index 0000000..3573d44 --- /dev/null +++ b/src/components/categories/KeywordList.tsx @@ -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(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 ( +
+

{t("categories.keywords")}

+ +
+ 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)]" + /> + 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")} + /> + +
+ +
+ {keywords.map((kw) => + editingId === kw.id ? ( +
+ 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 + /> + 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" + /> + + +
+ ) : ( + 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 && ( + + {kw.priority} + + )} + + + ) + )} + {keywords.length === 0 && ( +

{t("common.noResults")}

+ )} +
+
+ ); +} diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts new file mode 100644 index 0000000..efc8082 --- /dev/null +++ b/src/hooks/useCategories.ts @@ -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(); + 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, + }; +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index ab43101..6a32fe3 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ddbb3c1..45ed842 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx index 41d4eb0..7394a44 100644 --- a/src/pages/CategoriesPage.tsx +++ b/src/pages/CategoriesPage.tsx @@ -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 (
-

{t("categories.title")}

-
-

{t("common.noResults")}

+
+

{t("categories.title")}

+
+ + {state.error && ( +
+ {state.error} +
+ )} + + {state.isLoading ? ( +

{t("common.loading")}

+ ) : ( +
+
+ +
+ +
+ )}
); } diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts new file mode 100644 index 0000000..27153dc --- /dev/null +++ b/src/services/categoryService.ts @@ -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 { + const db = await getDb(); + return db.select( + `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 { + 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 { + 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 { + 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 { + const db = await getDb(); + const rows = await db.select>( + `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id = $1`, + [id] + ); + return rows[0]?.cnt ?? 0; +} + +export async function getKeywordsByCategoryId( + categoryId: number +): Promise { + const db = await getDb(); + return db.select( + `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 { + 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 { + 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 { + const db = await getDb(); + await db.execute(`UPDATE keywords SET is_active = 0 WHERE id = $1`, [id]); +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 751d0d7..46f2323 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -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 {