From 720f52bad600e7700c674ea45debd878f1c78129 Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Sat, 14 Feb 2026 12:59:11 +0000 Subject: [PATCH] feat: 12-month budget grid, import UX improvements, confirmation dialogs (v0.2.4) - Budget page: replace single-month view with 12-month annual grid with inline editing, split-evenly button, and year navigation - Import: pre-select only new files and sort them first, show "already imported" badge on previously imported files - Add confirmation modals for category re-initialization and import deletion (single and bulk), replacing native confirm() Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/budget/BudgetTable.tsx | 215 +++++++++++-------- src/components/budget/TemplateActions.tsx | 95 ++++++-- src/components/budget/YearNavigator.tsx | 28 +++ src/components/import/ImportHistoryPanel.tsx | 80 ++++++- src/components/import/SourceConfigPanel.tsx | 15 +- src/hooks/useBudget.ts | 151 +++++++------ src/hooks/useImportHistory.ts | 14 +- src/hooks/useImportWizard.ts | 17 +- src/i18n/locales/en.json | 29 ++- src/i18n/locales/fr.json | 29 ++- src/pages/BudgetPage.tsx | 19 +- src/pages/CategoriesPage.tsx | 41 +++- src/pages/ImportPage.tsx | 1 + src/services/budgetService.ts | 35 +++ src/shared/types/index.ts | 9 + 18 files changed, 561 insertions(+), 223 deletions(-) create mode 100644 src/components/budget/YearNavigator.tsx diff --git a/package.json b/package.json index 07a7011..a9be249 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.2.3", + "version": "0.2.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 68d9972..35655ed 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simpl-result" -version = "0.2.3" +version = "0.2.4" description = "Personal finance management app" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 8663ab8..28c08e3 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Simpl Résultat", - "version": "0.2.3", + "version": "0.2.4", "identifier": "com.simpl.resultat", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/budget/BudgetTable.tsx b/src/components/budget/BudgetTable.tsx index 328370c..cc9feb5 100644 --- a/src/components/budget/BudgetTable.tsx +++ b/src/components/budget/BudgetTable.tsx @@ -1,50 +1,79 @@ import { useState, useRef, useEffect, Fragment } from "react"; import { useTranslation } from "react-i18next"; -import type { BudgetRow } from "../../shared/types"; +import { SplitSquareHorizontal } from "lucide-react"; +import type { BudgetYearRow } from "../../shared/types"; -const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }); +const fmt = new Intl.NumberFormat("en-CA", { + style: "currency", + currency: "CAD", + minimumFractionDigits: 0, + maximumFractionDigits: 0, +}); + +const MONTH_KEYS = [ + "months.jan", "months.feb", "months.mar", "months.apr", + "months.may", "months.jun", "months.jul", "months.aug", + "months.sep", "months.oct", "months.nov", "months.dec", +] as const; interface BudgetTableProps { - rows: BudgetRow[]; - onUpdatePlanned: (categoryId: number, amount: number) => void; + rows: BudgetYearRow[]; + onUpdatePlanned: (categoryId: number, month: number, amount: number) => void; + onSplitEvenly: (categoryId: number, annualAmount: number) => void; } -export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) { +export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) { const { t } = useTranslation(); - const [editingCategoryId, setEditingCategoryId] = useState(null); + const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null); const [editingValue, setEditingValue] = useState(""); const inputRef = useRef(null); useEffect(() => { - if (editingCategoryId !== null && inputRef.current) { + if (editingCell && inputRef.current) { inputRef.current.focus(); inputRef.current.select(); } - }, [editingCategoryId]); + }, [editingCell]); - const handleStartEdit = (row: BudgetRow) => { - setEditingCategoryId(row.category_id); - setEditingValue(row.planned === 0 ? "" : String(row.planned)); + const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => { + setEditingCell({ categoryId, monthIdx }); + setEditingValue(currentValue === 0 ? "" : String(currentValue)); }; const handleSave = () => { - if (editingCategoryId === null) return; + if (!editingCell) return; const amount = parseFloat(editingValue) || 0; - onUpdatePlanned(editingCategoryId, amount); - setEditingCategoryId(null); + onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount); + setEditingCell(null); }; const handleCancel = () => { - setEditingCategoryId(null); + setEditingCell(null); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter") handleSave(); if (e.key === "Escape") handleCancel(); + if (e.key === "Tab") { + e.preventDefault(); + if (!editingCell) return; + const amount = parseFloat(editingValue) || 0; + onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount); + // Move to next month cell + const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1); + if (nextMonth >= 0 && nextMonth < 12) { + const row = rows.find((r) => r.category_id === editingCell.categoryId); + if (row) { + handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]); + } + } else { + setEditingCell(null); + } + } }; // Group rows by type - const grouped: Record = {}; + const grouped: Record = {}; for (const row of rows) { const key = row.category_type; if (!grouped[key]) grouped[key] = []; @@ -58,9 +87,17 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) transfer: "budget.transfers", }; - const totalPlanned = rows.reduce((s, r) => s + r.planned, 0); - const totalActual = rows.reduce((s, r) => s + Math.abs(r.actual), 0); - const totalDifference = totalPlanned - totalActual; + // Column totals + const monthTotals: number[] = Array(12).fill(0); + let annualTotal = 0; + for (const row of rows) { + for (let m = 0; m < 12; m++) { + monthTotals[m] += row.months[m]; + } + annualTotal += row.annual; + } + + const totalCols = 14; // category + annual + 12 months if (rows.length === 0) { return ( @@ -71,22 +108,21 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) } return ( -
- +
+
- - - - + {MONTH_KEYS.map((key) => ( + + ))} @@ -97,8 +133,8 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) @@ -106,82 +142,81 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) {group.map((row) => ( - - - - + {/* 12 month cells */} + {row.months.map((val, mIdx) => ( + + ))} ))} ); })} + {/* Totals row */} - - - - + + + {monthTotals.map((total, mIdx) => ( + + ))}
+ {t("budget.category")} - {t("budget.planned")} - - {t("budget.actual")} - - {t("budget.difference")} + + {t("budget.annual")} + {t(key)} +
{t(typeLabelKeys[type])}
+ {/* Category name - sticky */} +
- {row.category_name} + {row.category_name}
- {editingCategoryId === row.category_id ? ( - setEditingValue(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-2 py-1 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]" - /> - ) : ( - +
+ + {row.annual === 0 ? ( ) : ( - fmt.format(row.planned) + fmt.format(row.annual) )} - - )} -
- {row.actual === 0 ? ( - - ) : ( - fmt.format(Math.abs(row.actual)) - )} - - {row.planned === 0 && row.actual === 0 ? ( - - ) : ( - = 0 - ? "text-[var(--positive)]" - : "text-[var(--negative)]" - } - > - {fmt.format(row.difference)} - )} + {row.annual > 0 && ( + + )} + + {editingCell?.categoryId === row.category_id && editingCell.monthIdx === mIdx ? ( + setEditingValue(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + 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)]" + /> + ) : ( + + )} +
{t("common.total")}{fmt.format(totalPlanned)}{fmt.format(totalActual)} - = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" - } - > - {fmt.format(totalDifference)} - - {t("common.total")}{fmt.format(annualTotal)} + {fmt.format(total)} +
diff --git a/src/components/budget/TemplateActions.tsx b/src/components/budget/TemplateActions.tsx index 8f10ed7..56cb060 100644 --- a/src/components/budget/TemplateActions.tsx +++ b/src/components/budget/TemplateActions.tsx @@ -5,15 +5,23 @@ import type { BudgetTemplate } from "../../shared/types"; interface TemplateActionsProps { templates: BudgetTemplate[]; - onApply: (templateId: number) => void; + onApply: (templateId: number, month: number) => void; + onApplyAllMonths: (templateId: number) => void; onSave: (name: string, description?: string) => void; onDelete: (templateId: number) => void; disabled?: boolean; } +const MONTH_KEYS = [ + "months.jan", "months.feb", "months.mar", "months.apr", + "months.may", "months.jun", "months.jul", "months.aug", + "months.sep", "months.oct", "months.nov", "months.dec", +] as const; + export default function TemplateActions({ templates, onApply, + onApplyAllMonths, onSave, onDelete, disabled, @@ -22,6 +30,7 @@ export default function TemplateActions({ const [showApply, setShowApply] = useState(false); const [showSave, setShowSave] = useState(false); const [templateName, setTemplateName] = useState(""); + const [selectedTemplate, setSelectedTemplate] = useState(null); const applyRef = useRef(null); const saveRef = useRef(null); @@ -30,6 +39,7 @@ export default function TemplateActions({ const handler = (e: MouseEvent) => { if (showApply && applyRef.current && !applyRef.current.contains(e.target as Node)) { setShowApply(false); + setSelectedTemplate(null); } if (showSave && saveRef.current && !saveRef.current.contains(e.target as Node)) { setShowSave(false); @@ -53,12 +63,30 @@ export default function TemplateActions({ } }; + const handleSelectTemplate = (templateId: number) => { + setSelectedTemplate(templateId); + }; + + const handleApplyToMonth = (month: number) => { + if (selectedTemplate === null) return; + onApply(selectedTemplate, month); + setShowApply(false); + setSelectedTemplate(null); + }; + + const handleApplyAll = () => { + if (selectedTemplate === null) return; + onApplyAllMonths(selectedTemplate); + setShowApply(false); + setSelectedTemplate(null); + }; + return (
{/* Apply template */}
{showApply && ( -
- {templates.length === 0 ? ( -

- {t("budget.noTemplates")} -

- ) : ( - templates.map((tmpl) => ( -
{ onApply(tmpl.id); setShowApply(false); }} - > - {tmpl.name} - + {tmpl.name} + +
+ )) + ) + ) : ( + // Step 2: Pick which month(s) to apply to +
+

+ {t("budget.applyToMonth")} +

+
+ {t("budget.allMonths")}
- )) + {MONTH_KEYS.map((key, idx) => ( +
handleApplyToMonth(idx + 1)} + > + {t(key)} +
+ ))} +
)}
)} diff --git a/src/components/budget/YearNavigator.tsx b/src/components/budget/YearNavigator.tsx new file mode 100644 index 0000000..61817ed --- /dev/null +++ b/src/components/budget/YearNavigator.tsx @@ -0,0 +1,28 @@ +import { ChevronLeft, ChevronRight } from "lucide-react"; + +interface YearNavigatorProps { + year: number; + onNavigate: (delta: -1 | 1) => void; +} + +export default function YearNavigator({ year, onNavigate }: YearNavigatorProps) { + return ( +
+ + {year} + +
+ ); +} diff --git a/src/components/import/ImportHistoryPanel.tsx b/src/components/import/ImportHistoryPanel.tsx index c8d527e..904068a 100644 --- a/src/components/import/ImportHistoryPanel.tsx +++ b/src/components/import/ImportHistoryPanel.tsx @@ -1,5 +1,6 @@ +import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Trash2, Inbox } from "lucide-react"; +import { Trash2, Inbox, AlertTriangle } from "lucide-react"; import { useImportHistory } from "../../hooks/useImportHistory"; interface ImportHistoryPanelProps { @@ -11,6 +12,8 @@ export default function ImportHistoryPanel({ }: ImportHistoryPanelProps) { const { t } = useTranslation(); const { state, handleDelete, handleDeleteAll } = useImportHistory(onChanged); + const [confirmDelete, setConfirmDelete] = useState<{ id: number; filename: string; rowCount: number } | null>(null); + const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); return (
@@ -20,7 +23,7 @@ export default function ImportHistoryPanel({ {state.files.length > 0 && (
)} + + {/* Confirm delete single import */} + {confirmDelete && ( +
+
+
+
+ +
+

{t("common.delete")}

+
+

+ {confirmDelete.filename} +

+

+ {t("import.history.deleteConfirm", { count: confirmDelete.rowCount })} +

+
+ + +
+
+
+ )} + + {/* Confirm delete all imports */} + {confirmDeleteAll && ( +
+
+
+
+ +
+

{t("import.history.deleteAll")}

+
+

+ {t("import.history.deleteAllConfirm")} +

+
+ + +
+
+
+ )}
); } diff --git a/src/components/import/SourceConfigPanel.tsx b/src/components/import/SourceConfigPanel.tsx index 0a32a4c..5499a7a 100644 --- a/src/components/import/SourceConfigPanel.tsx +++ b/src/components/import/SourceConfigPanel.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Wand2 } from "lucide-react"; +import { Wand2, Check } from "lucide-react"; import type { ScannedSource, ScannedFile, @@ -13,6 +13,7 @@ interface SourceConfigPanelProps { source: ScannedSource; config: SourceConfig; selectedFiles: ScannedFile[]; + importedFileNames?: Set; headers: string[]; onConfigChange: (config: SourceConfig) => void; onFileToggle: (file: ScannedFile) => void; @@ -25,6 +26,7 @@ export default function SourceConfigPanel({ source, config, selectedFiles, + importedFileNames, headers, onConfigChange, onFileToggle, @@ -222,10 +224,13 @@ export default function SourceConfigPanel({ const isSelected = selectedFiles.some( (f) => f.file_path === file.file_path ); + const isImported = importedFileNames?.has(file.filename) ?? false; return (
@@ -44,8 +46,11 @@ export default function BudgetPage() {
)} - - + ); } diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx index bc0f512..284621d 100644 --- a/src/pages/CategoriesPage.tsx +++ b/src/pages/CategoriesPage.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; import { useTranslation } from "react-i18next"; -import { Plus, RotateCcw, List } from "lucide-react"; +import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; import { useCategories } from "../hooks/useCategories"; import CategoryTree from "../components/categories/CategoryTree"; @@ -10,6 +10,7 @@ import AllKeywordsPanel from "../components/categories/AllKeywordsPanel"; export default function CategoriesPage() { const { t } = useTranslation(); const [showAllKeywords, setShowAllKeywords] = useState(false); + const [showReinitConfirm, setShowReinitConfirm] = useState(false); const { state, selectCategory, @@ -25,9 +26,8 @@ export default function CategoriesPage() { } = useCategories(); const handleReinitialize = async () => { - if (confirm(t("categories.reinitializeConfirm"))) { - await reinitializeCategories(); - } + setShowReinitConfirm(false); + await reinitializeCategories(); }; const selectedCategory = @@ -55,7 +55,7 @@ export default function CategoriesPage() { {t("categories.allKeywords")} + + + + + )} ); } diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index 24e4a21..1e4ef14 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -87,6 +87,7 @@ export default function ImportPage() { source={state.selectedSource} config={state.sourceConfig} selectedFiles={state.selectedFiles} + importedFileNames={state.importedFilesBySource.get(state.selectedSource.folder_name)} headers={state.previewHeaders} onConfigChange={updateConfig} onFileToggle={toggleFile} diff --git a/src/services/budgetService.ts b/src/services/budgetService.ts index b3ee3f3..b37b337 100644 --- a/src/services/budgetService.ts +++ b/src/services/budgetService.ts @@ -74,6 +74,41 @@ export async function getActualsByCategory( ); } +export async function getBudgetEntriesForYear( + year: number +): Promise { + const db = await getDb(); + return db.select( + "SELECT * FROM budget_entries WHERE year = $1", + [year] + ); +} + +export async function upsertBudgetEntriesForYear( + categoryId: number, + year: number, + amounts: number[] +): Promise { + const db = await getDb(); + for (let m = 0; m < 12; m++) { + const month = m + 1; + const amount = amounts[m] ?? 0; + if (amount === 0) { + await db.execute( + "DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3", + [categoryId, year, month] + ); + } else { + await db.execute( + `INSERT INTO budget_entries (category_id, year, month, amount) + VALUES ($1, $2, $3, $4) + ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, updated_at = CURRENT_TIMESTAMP`, + [categoryId, year, month, amount] + ); + } + } +} + // Templates export async function getAllTemplates(): Promise { diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 9d637f0..52d98ae 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -131,6 +131,15 @@ export interface BudgetRow { notes?: string; } +export interface BudgetYearRow { + category_id: number; + category_name: string; + category_color: string; + category_type: "expense" | "income" | "transfer"; + months: number[]; // index 0-11 = Jan-Dec planned amounts + annual: number; // computed sum +} + export interface UserPreference { key: string; value: string;