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 */}