From ccdab1f06ab0a3287b1fb9ac81f4dd2dc9981b5b Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Sat, 14 Feb 2026 15:06:44 +0000 Subject: [PATCH] feat: add import config templates, budget/category fixes (v0.2.5) Add reusable import config templates so users can save and apply CSV parsing configurations across different import sources. Includes database table, service, hook integration, and template UI in the source config panel. Co-Authored-By: Claude Opus 4.6 --- src-tauri/src/database/schema.sql | 15 +++ src-tauri/src/lib.rs | 24 +++++ src/components/budget/BudgetTable.tsx | 78 +++++++++++--- src/components/categories/CategoryForm.tsx | 12 +++ src/components/import/SourceConfigPanel.tsx | 108 +++++++++++++++++++- src/hooks/useCategories.ts | 3 +- src/hooks/useImportWizard.ts | 70 +++++++++++++ src/i18n/locales/en.json | 11 +- src/i18n/locales/fr.json | 11 +- src/pages/ImportPage.tsx | 7 ++ src/services/budgetService.ts | 2 +- src/services/categoryService.ts | 11 +- src/services/importConfigTemplateService.ts | 36 +++++++ src/services/transactionService.ts | 2 +- src/shared/types/index.ts | 17 +++ 15 files changed, 380 insertions(+), 27 deletions(-) create mode 100644 src/services/importConfigTemplateService.ts diff --git a/src-tauri/src/database/schema.sql b/src-tauri/src/database/schema.sql index ebea4e0..d3648b0 100644 --- a/src-tauri/src/database/schema.sql +++ b/src-tauri/src/database/schema.sql @@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS categories ( icon TEXT, type TEXT NOT NULL DEFAULT 'expense', -- 'expense', 'income', 'transfer' is_active INTEGER NOT NULL DEFAULT 1, + is_inputable INTEGER NOT NULL DEFAULT 1, sort_order INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL @@ -136,6 +137,20 @@ CREATE TABLE IF NOT EXISTS budget_template_entries ( UNIQUE(template_id, category_id) ); +CREATE TABLE IF NOT EXISTS import_config_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY', + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + column_mapping TEXT NOT NULL, + amount_mode TEXT NOT NULL DEFAULT 'single', + sign_convention TEXT NOT NULL DEFAULT 'negative_expense', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + CREATE TABLE IF NOT EXISTS user_preferences ( key TEXT PRIMARY KEY, value TEXT NOT NULL, diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2b486c5..699ae8c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -24,6 +24,30 @@ pub fn run() { sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;", kind: MigrationKind::Up, }, + Migration { + version: 4, + description: "add is_inputable to categories", + sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;", + kind: MigrationKind::Up, + }, + Migration { + version: 5, + description: "create import_config_templates table", + sql: "CREATE TABLE IF NOT EXISTS import_config_templates ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + delimiter TEXT NOT NULL DEFAULT ';', + encoding TEXT NOT NULL DEFAULT 'utf-8', + date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY', + skip_lines INTEGER NOT NULL DEFAULT 0, + has_header INTEGER NOT NULL DEFAULT 1, + column_mapping TEXT NOT NULL, + amount_mode TEXT NOT NULL DEFAULT 'single', + sign_convention TEXT NOT NULL DEFAULT 'negative_expense', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + );", + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index cc9feb5..b8574e3 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; -import { SplitSquareHorizontal } from "lucide-react"; +import { AlertTriangle } from "lucide-react"; import type { BudgetYearRow } from "../../shared/types"; const fmt = new Intl.NumberFormat("en-CA", { @@ -25,8 +25,10 @@ interface BudgetTableProps { export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) { const { t } = useTranslation(); const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null); + const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null); const [editingValue, setEditingValue] = useState(""); const inputRef = useRef(null); + const annualInputRef = useRef(null); useEffect(() => { if (editingCell && inputRef.current) { @@ -35,11 +37,25 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu } }, [editingCell]); + useEffect(() => { + if (editingAnnual && annualInputRef.current) { + annualInputRef.current.focus(); + annualInputRef.current.select(); + } + }, [editingAnnual]); + const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => { + setEditingAnnual(null); setEditingCell({ categoryId, monthIdx }); setEditingValue(currentValue === 0 ? "" : String(currentValue)); }; + const handleStartEditAnnual = (categoryId: number, currentValue: number) => { + setEditingCell(null); + setEditingAnnual({ categoryId }); + setEditingValue(currentValue === 0 ? "" : String(currentValue)); + }; + const handleSave = () => { if (!editingCell) return; const amount = parseFloat(editingValue) || 0; @@ -47,8 +63,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu setEditingCell(null); }; + const handleSaveAnnual = () => { + if (!editingAnnual) return; + const amount = parseFloat(editingValue) || 0; + onSplitEvenly(editingAnnual.categoryId, amount); + setEditingAnnual(null); + }; + const handleCancel = () => { setEditingCell(null); + setEditingAnnual(null); }; const handleKeyDown = (e: React.KeyboardEvent) => { @@ -72,6 +96,11 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu } }; + const handleAnnualKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter") handleSaveAnnual(); + if (e.key === "Escape") handleCancel(); + }; + // Group rows by type const grouped: Record = {}; for (const row of rows) { @@ -154,26 +183,41 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu {row.category_name} - {/* Annual total + split button */} + {/* Annual total — editable */} -
- - {row.annual === 0 ? ( - - ) : ( - fmt.format(row.annual) - )} - - {row.annual > 0 && ( + {editingAnnual?.categoryId === row.category_id ? ( + setEditingValue(e.target.value)} + onBlur={handleSaveAnnual} + onKeyDown={handleAnnualKeyDown} + className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" + /> + ) : ( +
- )} -
+ {(() => { + const monthSum = row.months.reduce((s, v) => s + v, 0); + return row.annual !== 0 && Math.abs(row.annual - monthSum) > 0.01 ? ( + + + + ) : null; + })()} +
+ )} {/* 12 month cells */} {row.months.map((val, mIdx) => ( diff --git a/src/components/categories/CategoryForm.tsx b/src/components/categories/CategoryForm.tsx index 133c43f..aea001f 100644 --- a/src/components/categories/CategoryForm.tsx +++ b/src/components/categories/CategoryForm.tsx @@ -119,6 +119,18 @@ export default function CategoryForm({ +
+ setForm({ ...form, is_inputable: e.target.checked })} + className="w-4 h-4 rounded border-[var(--border)] accent-[var(--primary)]" + /> + + {t("categories.isInputableHint")} +
+
; headers: string[]; + configTemplates: ImportConfigTemplate[]; onConfigChange: (config: SourceConfig) => void; onFileToggle: (file: ScannedFile) => void; onSelectAllFiles: () => void; onAutoDetect: () => void; + onSaveAsTemplate: (name: string) => void; + onApplyTemplate: (id: number) => void; + onDeleteTemplate: (id: number) => void; isLoading?: boolean; } @@ -28,13 +34,19 @@ export default function SourceConfigPanel({ selectedFiles, importedFileNames, headers, + configTemplates, onConfigChange, onFileToggle, onSelectAllFiles, onAutoDetect, + onSaveAsTemplate, + onApplyTemplate, + onDeleteTemplate, isLoading, }: SourceConfigPanelProps) { const { t } = useTranslation(); + const [showSaveTemplate, setShowSaveTemplate] = useState(false); + const [templateName, setTemplateName] = useState(""); const selectClass = "w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]"; @@ -60,6 +72,100 @@ export default function SourceConfigPanel({
+ {/* Template row */} +
+
+ + + {configTemplates.length > 0 && ( +
+ {configTemplates.map((tpl) => ( + + ))} +
+ )} +
+ + {showSaveTemplate ? ( +
+ setTemplateName(e.target.value)} + placeholder={t("import.config.templateName")} + className={inputClass + " w-48"} + autoFocus + onKeyDown={(e) => { + if (e.key === "Enter" && templateName.trim()) { + onSaveAsTemplate(templateName.trim()); + setTemplateName(""); + setShowSaveTemplate(false); + } else if (e.key === "Escape") { + setShowSaveTemplate(false); + setTemplateName(""); + } + }} + /> + + +
+ ) : ( + + )} +
+ {/* Source name */}