From 5f5696c29abdfb03d1d2eba7485319f6f9aa53ad Mon Sep 17 00:00:00 2001 From: Le-King-Fu Date: Thu, 12 Feb 2026 00:58:43 +0000 Subject: [PATCH] feat: add Budget and Adjustments pages with full functionality Budget: monthly data grid with inline-editable planned amounts per category, actuals from transactions, difference coloring, month navigation, and save/apply/delete budget templates. Adjustments: split-panel CRUD for manual adjustment entries. Both features include FR/EN translations and follow existing service/hook/component patterns. Co-Authored-By: Claude Opus 4.6 --- masterplan.md | 105 +++++++ .../adjustments/AdjustmentDetailPanel.tsx | 187 +++++++++++ src/components/adjustments/AdjustmentForm.tsx | 186 +++++++++++ .../adjustments/AdjustmentListPanel.tsx | 69 ++++ src/components/budget/BudgetSummaryCards.tsx | 58 ++++ src/components/budget/BudgetTable.tsx | 190 +++++++++++ src/components/budget/MonthNavigator.tsx | 36 +++ src/components/budget/TemplateActions.tsx | 139 +++++++++ src/hooks/useAdjustments.ts | 295 ++++++++++++++++++ src/hooks/useBudget.ts | 251 +++++++++++++++ src/i18n/locales/en.json | 34 +- src/i18n/locales/fr.json | 34 +- src/pages/AdjustmentsPage.tsx | 96 +++++- src/pages/BudgetPage.tsx | 47 ++- src/services/adjustmentService.ts | 102 ++++++ src/services/budgetService.ts | 136 ++++++++ src/shared/types/index.ts | 11 + tasks/lessons.md | 37 +++ tasks/todo.md | 29 ++ 19 files changed, 2017 insertions(+), 25 deletions(-) create mode 100644 masterplan.md create mode 100644 src/components/adjustments/AdjustmentDetailPanel.tsx create mode 100644 src/components/adjustments/AdjustmentForm.tsx create mode 100644 src/components/adjustments/AdjustmentListPanel.tsx create mode 100644 src/components/budget/BudgetSummaryCards.tsx create mode 100644 src/components/budget/BudgetTable.tsx create mode 100644 src/components/budget/MonthNavigator.tsx create mode 100644 src/components/budget/TemplateActions.tsx create mode 100644 src/hooks/useAdjustments.ts create mode 100644 src/hooks/useBudget.ts create mode 100644 src/services/adjustmentService.ts create mode 100644 src/services/budgetService.ts create mode 100644 tasks/lessons.md create mode 100644 tasks/todo.md diff --git a/masterplan.md b/masterplan.md new file mode 100644 index 0000000..fe17ac6 --- /dev/null +++ b/masterplan.md @@ -0,0 +1,105 @@ +# Simpl'Résultat — Masterplan + +## Overview +Local desktop personal finance app. Tauri v2 (Rust) + React + TypeScript + Tailwind CSS + SQLite. Bilingual (FR/EN). Windows-only release via GitHub Actions. + +--- + +## Current State (as of v0.1.0) + +### Pages (8) +| Page | Status | Description | +|------|--------|-------------| +| Dashboard | Done | Balance/income/expenses cards, category pie chart, recent transactions, period selector | +| Import | Done | Multi-step wizard: folder scan → source config → column mapping → preview → duplicate check → import → report. Import history with delete | +| Transactions | Done | Filterable table (search, category, source, date range), pagination, inline category edit via searchable combobox, auto-categorize, notes | +| Categories | Done | Tree view with create/edit/delete, keyword management (priority-based), supplier mapping, color picker | +| Adjustments | Done | One-time & recurring adjustments by category | +| Budget | Done | Monthly per-category budgets with templates | +| Reports | Done | Monthly trends chart, category bar chart, category-over-time chart | +| Settings | Done | About card (version), in-app updater (check → download → install & restart), data safety notice | + +### Backend (Rust Commands) +- `scan_import_folder` — recursive folder scan for CSV/TXT +- `read_file_content` — encoding-aware file read (UTF-8, Windows-1252, ISO-8859-15) +- `hash_file` — SHA256 for duplicate detection +- `detect_encoding` — smart encoding detection +- `get_file_preview` — first N lines preview +- `pick_folder` — native folder picker dialog + +### Database (11 tables) +**Core:** transactions, categories, suppliers, keywords +**Import:** import_sources, imported_files +**Planning:** budget_entries, budget_templates, adjustments, adjustment_entries +**User:** user_preferences (key-value: language, theme, currency, date_format) + +### Services (9) +db, transactionService, categoryService, importSourceService, importedFileService, dashboardService, reportService, categorizationService, userPreferenceService + +### Hooks (7) +useDashboard, useTransactions, useCategories, useReports, useImportWizard, useImportHistory, useUpdater + +### CI/CD +- GitHub Actions release workflow on `v*` tag push +- Windows-only (Ubuntu/macOS commented out) +- NSIS + MSI installers +- Tauri updater with signed artifacts (`latest.json` in release assets) +- Signing keys required: `TAURI_SIGNING_PRIVATE_KEY` + `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` secrets + +### Infrastructure Done +- Tailwind CSS v4 with CSS custom properties (light mode palette: blue primary, cream background, terracotta accent) +- react-i18next with FR default + EN +- lucide-react icons +- useReducer state management pattern (no Redux) +- Parameterized SQL queries ($1, $2...) +- Seeded categories & keywords in migration + +--- + +## Pending / Not Yet Started + +### Updater Setup (One-time manual) +- [ ] Generate signing keys: `npx tauri signer generate -w ~/.tauri/simpl-resultat.key` +- [ ] Add GitHub Secrets: `TAURI_SIGNING_PRIVATE_KEY`, `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` +- [ ] Replace `REPLACE_WITH_PUBLIC_KEY` in `tauri.conf.json` with contents of `.key.pub` +- [ ] Tag + push a release to verify `latest.json` appears in assets + +### Features Not Implemented +- **Dark mode** — CSS variables defined but no toggle yet +- **Data export** — Reports page has an "Export" button label but no implementation +- **Transaction splitting** — Schema supports it (`parent_transaction_id`) but no UI +- **Supplier management UI** — Suppliers table exists, auto-linked during import, but no dedicated management page +- **User preferences UI** — Settings page exists but doesn't yet expose language/theme/currency/date format preferences (language toggle is in sidebar) +- **Multi-platform builds** — Ubuntu and macOS targets commented out in release workflow +- **Backup / restore** — No database backup feature yet + +--- + +## Tech Stack +| Layer | Technology | +|-------|-----------| +| Desktop shell | Tauri v2 (Rust) | +| Frontend | React 18 + TypeScript | +| Styling | Tailwind CSS v4 + CSS custom properties | +| Icons | lucide-react | +| Charts | Recharts | +| CSV parsing | PapaParse | +| i18n | react-i18next (FR + EN) | +| Database | SQLite via @tauri-apps/plugin-sql | +| State | useReducer hooks | +| Build/CI | Vite + GitHub Actions + tauri-action | + +## Key File Paths +| Path | Purpose | +|------|---------| +| `src/shared/types/index.ts` | All TypeScript interfaces | +| `src/shared/constants/index.ts` | Nav items, app name, DB name | +| `src/services/*.ts` | Data access layer (getDb + typed queries) | +| `src/hooks/*.ts` | State management (useReducer pattern) | +| `src/pages/*.tsx` | Page components | +| `src/components/` | UI components by feature area | +| `src-tauri/src/lib.rs` | Tauri plugin registration | +| `src-tauri/src/commands/` | Rust IPC commands | +| `src-tauri/src/database/` | Schema + seed migrations | +| `src-tauri/tauri.conf.json` | Tauri config (updater, bundle, window) | +| `.github/workflows/release.yml` | Release CI/CD | diff --git a/src/components/adjustments/AdjustmentDetailPanel.tsx b/src/components/adjustments/AdjustmentDetailPanel.tsx new file mode 100644 index 0000000..64db9d9 --- /dev/null +++ b/src/components/adjustments/AdjustmentDetailPanel.tsx @@ -0,0 +1,187 @@ +import { useTranslation } from "react-i18next"; +import { Pencil, RefreshCw } from "lucide-react"; +import type { Adjustment, Category } from "../../shared/types"; +import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService"; +import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; +import AdjustmentForm from "./AdjustmentForm"; + +interface Props { + selectedAdjustment: Adjustment | null; + entries: AdjustmentEntryWithCategory[]; + categories: Category[]; + editingAdjustment: AdjustmentFormData | null; + editingEntries: EntryFormData[]; + isCreating: boolean; + isSaving: boolean; + onStartEditing: () => void; + onCancelEditing: () => void; + onSave: (data: AdjustmentFormData, entries: EntryFormData[]) => void; + onDelete: (id: number) => void; +} + +export default function AdjustmentDetailPanel({ + selectedAdjustment, + entries, + categories, + editingAdjustment, + editingEntries, + isCreating, + isSaving, + onStartEditing, + onCancelEditing, + onSave, + onDelete, +}: Props) { + const { t } = useTranslation(); + + const handleDelete = () => { + if (!selectedAdjustment) return; + if (!confirm(t("adjustments.deleteConfirm"))) return; + onDelete(selectedAdjustment.id); + }; + + // No selection and not creating + if (!selectedAdjustment && !isCreating) { + return ( +
+

{t("adjustments.selectAdjustment")}

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

{t("adjustments.newAdjustment")}

+ +
+ ); + } + + if (!selectedAdjustment) return null; + + // Editing existing + if (editingAdjustment) { + return ( +
+

{t("adjustments.editAdjustment")}

+ +
+ ); + } + + // Read-only view + const total = entries.reduce((sum, e) => sum + e.amount, 0); + + return ( +
+
+
+

{selectedAdjustment.name}

+ {selectedAdjustment.is_recurring && ( + + )} +
+ +
+ +
+
+ {t("adjustments.date")} +

{selectedAdjustment.date}

+
+
+ {t("adjustments.recurring")} +

{selectedAdjustment.is_recurring ? "Yes" : "No"}

+
+ {selectedAdjustment.description && ( +
+ {t("adjustments.description")} +

{selectedAdjustment.description}

+
+ )} +
+ + {/* Entries table */} +
+

{t("adjustments.entries")}

+ {entries.length === 0 ? ( +

{t("adjustments.noEntries")}

+ ) : ( + + + + + + + + + + {entries.map((entry) => ( + + + + + + ))} + + + + + +
{t("adjustments.category")}{t("adjustments.description")}{t("adjustments.amount")}
+
+ + {entry.category_name} +
+
+ {entry.description || "—"} + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`} + > + {entry.amount >= 0 ? "+" : ""} + {entry.amount.toFixed(2)} +
+ {t("adjustments.total")} + = 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`} + > + {total >= 0 ? "+" : ""} + {total.toFixed(2)} +
+ )} +
+
+ ); +} diff --git a/src/components/adjustments/AdjustmentForm.tsx b/src/components/adjustments/AdjustmentForm.tsx new file mode 100644 index 0000000..251f236 --- /dev/null +++ b/src/components/adjustments/AdjustmentForm.tsx @@ -0,0 +1,186 @@ +import { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Trash2 } from "lucide-react"; +import type { Category } from "../../shared/types"; +import type { AdjustmentFormData, EntryFormData } from "../../hooks/useAdjustments"; + +interface Props { + initialData: AdjustmentFormData; + initialEntries: EntryFormData[]; + categories: Category[]; + isCreating: boolean; + isSaving: boolean; + onSave: (data: AdjustmentFormData, entries: EntryFormData[]) => void; + onCancel: () => void; + onDelete?: () => void; +} + +export default function AdjustmentForm({ + initialData, + initialEntries, + categories, + isCreating, + isSaving, + onSave, + onCancel, + onDelete, +}: Props) { + const { t } = useTranslation(); + const [form, setForm] = useState(initialData); + const [entries, setEntries] = useState(initialEntries); + + useEffect(() => { + setForm(initialData); + setEntries(initialEntries); + }, [initialData, initialEntries]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim()) return; + onSave({ ...form, name: form.name.trim() }, entries); + }; + + const addEntry = () => { + const defaultCategoryId = categories.length > 0 ? categories[0].id : 0; + setEntries([...entries, { category_id: defaultCategoryId, amount: 0, description: "" }]); + }; + + const updateEntryField = (index: number, field: keyof EntryFormData, value: unknown) => { + setEntries(entries.map((e, i) => (i === index ? { ...e, [field]: value } : e))); + }; + + const removeEntry = (index: number) => { + setEntries(entries.filter((_, i) => i !== index)); + }; + + 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 + /> +
+ +
+ + setForm({ ...form, date: 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)]" + /> +
+ +
+ +