feat: add Budget and Adjustments pages with full functionality
Some checks failed
Release / build (windows-latest) (push) Has been cancelled

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 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-12 00:58:43 +00:00
parent 474c7b947a
commit 5f5696c29a
19 changed files with 2017 additions and 25 deletions

105
masterplan.md Normal file
View file

@ -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 |

View file

@ -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 (
<div className="flex-1 flex items-center justify-center bg-[var(--card)] rounded-xl border border-[var(--border)] p-8">
<p className="text-[var(--muted-foreground)]">{t("adjustments.selectAdjustment")}</p>
</div>
);
}
// Creating new
if (isCreating && editingAdjustment) {
return (
<div className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">{t("adjustments.newAdjustment")}</h2>
<AdjustmentForm
initialData={editingAdjustment}
initialEntries={editingEntries}
categories={categories}
isCreating
isSaving={isSaving}
onSave={onSave}
onCancel={onCancelEditing}
/>
</div>
);
}
if (!selectedAdjustment) return null;
// Editing existing
if (editingAdjustment) {
return (
<div className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">{t("adjustments.editAdjustment")}</h2>
<AdjustmentForm
initialData={editingAdjustment}
initialEntries={editingEntries}
categories={categories}
isCreating={false}
isSaving={isSaving}
onSave={onSave}
onCancel={onCancelEditing}
onDelete={handleDelete}
/>
</div>
);
}
// Read-only view
const total = entries.reduce((sum, e) => sum + e.amount, 0);
return (
<div className="flex-1 bg-[var(--card)] rounded-xl border border-[var(--border)] p-6 overflow-y-auto">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<h2 className="text-lg font-semibold">{selectedAdjustment.name}</h2>
{selectedAdjustment.is_recurring && (
<RefreshCw size={14} className="text-[var(--primary)]" />
)}
</div>
<button
onClick={onStartEditing}
className="flex items-center gap-1 px-3 py-1.5 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
<Pencil size={14} />
{t("common.edit")}
</button>
</div>
<div className="grid grid-cols-2 gap-4 mb-6 text-sm">
<div>
<span className="text-[var(--muted-foreground)]">{t("adjustments.date")}</span>
<p className="font-medium">{selectedAdjustment.date}</p>
</div>
<div>
<span className="text-[var(--muted-foreground)]">{t("adjustments.recurring")}</span>
<p className="font-medium">{selectedAdjustment.is_recurring ? "Yes" : "No"}</p>
</div>
{selectedAdjustment.description && (
<div className="col-span-2">
<span className="text-[var(--muted-foreground)]">{t("adjustments.description")}</span>
<p className="font-medium">{selectedAdjustment.description}</p>
</div>
)}
</div>
{/* Entries table */}
<div className="border-t border-[var(--border)] pt-4">
<h3 className="text-sm font-semibold mb-3">{t("adjustments.entries")}</h3>
{entries.length === 0 ? (
<p className="text-xs text-[var(--muted-foreground)]">{t("adjustments.noEntries")}</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="text-left text-[var(--muted-foreground)] text-xs">
<th className="pb-2 font-medium">{t("adjustments.category")}</th>
<th className="pb-2 font-medium">{t("adjustments.description")}</th>
<th className="pb-2 font-medium text-right">{t("adjustments.amount")}</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
<tr key={entry.id} className="border-t border-[var(--border)]/50">
<td className="py-2">
<div className="flex items-center gap-2">
<span
className="w-2.5 h-2.5 rounded-full flex-shrink-0"
style={{ backgroundColor: entry.category_color }}
/>
{entry.category_name}
</div>
</td>
<td className="py-2 text-[var(--muted-foreground)]">
{entry.description || "—"}
</td>
<td
className={`py-2 text-right font-medium ${
entry.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{entry.amount >= 0 ? "+" : ""}
{entry.amount.toFixed(2)}
</td>
</tr>
))}
<tr className="border-t border-[var(--border)]">
<td className="py-2 font-semibold" colSpan={2}>
{t("adjustments.total")}
</td>
<td
className={`py-2 text-right font-semibold ${
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{total >= 0 ? "+" : ""}
{total.toFixed(2)}
</td>
</tr>
</tbody>
</table>
)}
</div>
</div>
);
}

View file

@ -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<AdjustmentFormData>(initialData);
const [entries, setEntries] = useState<EntryFormData[]>(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 (
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label className="block text-sm font-medium mb-1">{t("adjustments.name")}</label>
<input
type="text"
value={form.name}
onChange={(e) => 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
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("adjustments.date")}</label>
<input
type="date"
value={form.date}
onChange={(e) => 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)]"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">{t("adjustments.description")}</label>
<textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
rows={2}
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)] resize-none"
/>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={form.is_recurring}
onChange={(e) => setForm({ ...form, is_recurring: e.target.checked })}
className="rounded border-[var(--border)]"
/>
{t("adjustments.recurring")}
</label>
{/* Entries */}
<div className="border-t border-[var(--border)] pt-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-sm font-semibold">{t("adjustments.entries")}</h3>
<button
type="button"
onClick={addEntry}
className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs text-[var(--primary)] hover:bg-[var(--primary)]/10"
>
<Plus size={14} />
{t("adjustments.addEntry")}
</button>
</div>
{entries.length === 0 ? (
<p className="text-xs text-[var(--muted-foreground)]">{t("adjustments.noEntries")}</p>
) : (
<div className="flex flex-col gap-2">
{entries.map((entry, index) => (
<div key={index} className="flex items-center gap-2">
<select
value={entry.category_id}
onChange={(e) => updateEntryField(index, "category_id", Number(e.target.value))}
className="flex-1 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
>
{categories.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<input
type="number"
step="0.01"
value={entry.amount}
onChange={(e) => updateEntryField(index, "amount", Number(e.target.value))}
className="w-28 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm text-right focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
placeholder={t("adjustments.amount")}
/>
<input
type="text"
value={entry.description}
onChange={(e) => updateEntryField(index, "description", e.target.value)}
className="w-36 px-2 py-1.5 rounded-lg border border-[var(--border)] bg-[var(--card)] text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
placeholder={t("adjustments.description")}
/>
<button
type="button"
onClick={() => removeEntry(index)}
className="p-1.5 rounded-lg text-[var(--negative)] hover:bg-[var(--negative)]/10"
>
<Trash2 size={14} />
</button>
</div>
))}
</div>
)}
</div>
<div className="flex items-center gap-2 pt-2">
<button
type="submit"
disabled={isSaving || !form.name.trim()}
className="px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90 disabled:opacity-50"
>
{t("common.save")}
</button>
<button
type="button"
onClick={onCancel}
className="px-4 py-2 rounded-lg border border-[var(--border)] text-sm hover:bg-[var(--muted)]"
>
{t("common.cancel")}
</button>
{!isCreating && onDelete && (
<button
type="button"
onClick={onDelete}
className="ml-auto px-3 py-2 rounded-lg text-[var(--negative)] hover:bg-[var(--negative)]/10 text-sm flex items-center gap-1"
>
<Trash2 size={14} />
{t("common.delete")}
</button>
)}
</div>
</form>
);
}

View file

@ -0,0 +1,69 @@
import { useTranslation } from "react-i18next";
import { RefreshCw } from "lucide-react";
import type { Adjustment } from "../../shared/types";
import type { AdjustmentEntryWithCategory } from "../../services/adjustmentService";
interface Props {
adjustments: Adjustment[];
selectedId: number | null;
onSelect: (id: number) => void;
entriesByAdjustment: Map<number, AdjustmentEntryWithCategory[]>;
}
export default function AdjustmentListPanel({
adjustments,
selectedId,
onSelect,
entriesByAdjustment,
}: Props) {
const { t } = useTranslation();
if (adjustments.length === 0) {
return (
<div className="flex items-center justify-center h-full text-[var(--muted-foreground)] text-sm">
{t("common.noResults")}
</div>
);
}
return (
<div className="flex flex-col gap-0.5">
{adjustments.map((adj) => {
const isSelected = adj.id === selectedId;
const entries = entriesByAdjustment.get(adj.id);
const total = entries
? entries.reduce((sum, e) => sum + e.amount, 0)
: null;
return (
<button
key={adj.id}
onClick={() => onSelect(adj.id)}
className={`w-full flex flex-col gap-1 px-3 py-2.5 text-left rounded-lg transition-colors
${isSelected ? "bg-[var(--muted)] border-l-2 border-[var(--primary)]" : "hover:bg-[var(--muted)]/50"}`}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm truncate">{adj.name}</span>
{adj.is_recurring && (
<RefreshCw size={12} className="text-[var(--primary)] flex-shrink-0" />
)}
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-[var(--muted-foreground)]">{adj.date}</span>
{total !== null && (
<span
className={`text-xs font-medium ${
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{total >= 0 ? "+" : ""}
{total.toFixed(2)}
</span>
)}
</div>
</button>
);
})}
</div>
);
}

View file

@ -0,0 +1,58 @@
import { useTranslation } from "react-i18next";
import { Target, Receipt, TrendingUp, TrendingDown } from "lucide-react";
import type { BudgetRow } from "../../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
interface BudgetSummaryCardsProps {
rows: BudgetRow[];
}
export default function BudgetSummaryCards({ rows }: BudgetSummaryCardsProps) {
const { t } = useTranslation();
const totalPlanned = rows.reduce((sum, r) => sum + r.planned, 0);
const totalActual = rows.reduce((sum, r) => sum + Math.abs(r.actual), 0);
const totalDifference = totalPlanned - totalActual;
const DiffIcon = totalDifference >= 0 ? TrendingUp : TrendingDown;
const diffColor = totalDifference >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]";
const cards = [
{
labelKey: "budget.totalPlanned",
value: fmt.format(totalPlanned),
icon: Target,
color: "text-[var(--primary)]",
},
{
labelKey: "budget.totalActual",
value: fmt.format(totalActual),
icon: Receipt,
color: "text-[var(--muted-foreground)]",
},
{
labelKey: "budget.totalDifference",
value: fmt.format(totalDifference),
icon: DiffIcon,
color: diffColor,
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
{cards.map((card) => (
<div
key={card.labelKey}
className="bg-[var(--card)] rounded-xl p-5 border border-[var(--border)] shadow-sm"
>
<div className="flex items-center justify-between mb-3">
<span className="text-sm text-[var(--muted-foreground)]">{t(card.labelKey)}</span>
<card.icon size={20} className={card.color} />
</div>
<p className="text-2xl font-semibold">{card.value}</p>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,190 @@
import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { BudgetRow } from "../../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
interface BudgetTableProps {
rows: BudgetRow[];
onUpdatePlanned: (categoryId: number, amount: number) => void;
}
export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) {
const { t } = useTranslation();
const [editingCategoryId, setEditingCategoryId] = useState<number | null>(null);
const [editingValue, setEditingValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingCategoryId !== null && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingCategoryId]);
const handleStartEdit = (row: BudgetRow) => {
setEditingCategoryId(row.category_id);
setEditingValue(row.planned === 0 ? "" : String(row.planned));
};
const handleSave = () => {
if (editingCategoryId === null) return;
const amount = parseFloat(editingValue) || 0;
onUpdatePlanned(editingCategoryId, amount);
setEditingCategoryId(null);
};
const handleCancel = () => {
setEditingCategoryId(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
};
// Group rows by type
const grouped: Record<string, BudgetRow[]> = {};
for (const row of rows) {
const key = row.category_type;
if (!grouped[key]) grouped[key] = [];
grouped[key].push(row);
}
const typeOrder = ["expense", "income", "transfer"] as const;
const typeLabelKeys: Record<string, string> = {
expense: "budget.expenses",
income: "budget.income",
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;
if (rows.length === 0) {
return (
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("budget.noCategories")}</p>
</div>
);
}
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left py-3 px-4 font-medium text-[var(--muted-foreground)]">
{t("budget.category")}
</th>
<th className="text-right py-3 px-4 font-medium text-[var(--muted-foreground)] w-36">
{t("budget.planned")}
</th>
<th className="text-right py-3 px-4 font-medium text-[var(--muted-foreground)] w-36">
{t("budget.actual")}
</th>
<th className="text-right py-3 px-4 font-medium text-[var(--muted-foreground)] w-36">
{t("budget.difference")}
</th>
</tr>
</thead>
<tbody>
{typeOrder.map((type) => {
const group = grouped[type];
if (!group || group.length === 0) return null;
return (
<Fragment key={type}>
<tr>
<td
colSpan={4}
className="py-2 px-4 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
>
{t(typeLabelKeys[type])}
</td>
</tr>
{group.map((row) => (
<tr
key={row.category_id}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)] transition-colors"
>
<td className="py-2.5 px-4">
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
<span>{row.category_name}</span>
</div>
</td>
<td className="py-2.5 px-4 text-right">
{editingCategoryId === row.category_id ? (
<input
ref={inputRef}
type="number"
step="0.01"
value={editingValue}
onChange={(e) => 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)]"
/>
) : (
<button
onClick={() => handleStartEdit(row)}
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text"
>
{row.planned === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
fmt.format(row.planned)
)}
</button>
)}
</td>
<td className="py-2.5 px-4 text-right">
{row.actual === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
fmt.format(Math.abs(row.actual))
)}
</td>
<td className="py-2.5 px-4 text-right">
{row.planned === 0 && row.actual === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
<span
className={
row.difference >= 0
? "text-[var(--positive)]"
: "text-[var(--negative)]"
}
>
{fmt.format(row.difference)}
</span>
)}
</td>
</tr>
))}
</Fragment>
);
})}
<tr className="bg-[var(--muted)] font-semibold">
<td className="py-3 px-4">{t("common.total")}</td>
<td className="py-3 px-4 text-right">{fmt.format(totalPlanned)}</td>
<td className="py-3 px-4 text-right">{fmt.format(totalActual)}</td>
<td className="py-3 px-4 text-right">
<span
className={
totalDifference >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}
>
{fmt.format(totalDifference)}
</span>
</td>
</tr>
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,36 @@
import { useTranslation } from "react-i18next";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface MonthNavigatorProps {
year: number;
month: number;
onNavigate: (delta: -1 | 1) => void;
}
export default function MonthNavigator({ year, month, onNavigate }: MonthNavigatorProps) {
const { i18n } = useTranslation();
const label = new Intl.DateTimeFormat(i18n.language, { month: "long", year: "numeric" }).format(
new Date(year, month - 1)
);
return (
<div className="flex items-center gap-1">
<button
onClick={() => onNavigate(-1)}
className="p-1.5 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
aria-label="Previous month"
>
<ChevronLeft size={18} />
</button>
<span className="min-w-[10rem] text-center font-medium capitalize">{label}</span>
<button
onClick={() => onNavigate(1)}
className="p-1.5 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
aria-label="Next month"
>
<ChevronRight size={18} />
</button>
</div>
);
}

View file

@ -0,0 +1,139 @@
import { useState, useRef, useEffect } from "react";
import { useTranslation } from "react-i18next";
import { BookTemplate, Save, Trash2 } from "lucide-react";
import type { BudgetTemplate } from "../../shared/types";
interface TemplateActionsProps {
templates: BudgetTemplate[];
onApply: (templateId: number) => void;
onSave: (name: string, description?: string) => void;
onDelete: (templateId: number) => void;
disabled?: boolean;
}
export default function TemplateActions({
templates,
onApply,
onSave,
onDelete,
disabled,
}: TemplateActionsProps) {
const { t } = useTranslation();
const [showApply, setShowApply] = useState(false);
const [showSave, setShowSave] = useState(false);
const [templateName, setTemplateName] = useState("");
const applyRef = useRef<HTMLDivElement>(null);
const saveRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!showApply && !showSave) return;
const handler = (e: MouseEvent) => {
if (showApply && applyRef.current && !applyRef.current.contains(e.target as Node)) {
setShowApply(false);
}
if (showSave && saveRef.current && !saveRef.current.contains(e.target as Node)) {
setShowSave(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [showApply, showSave]);
const handleSave = () => {
if (!templateName.trim()) return;
onSave(templateName.trim());
setTemplateName("");
setShowSave(false);
};
const handleDelete = (e: React.MouseEvent, templateId: number) => {
e.stopPropagation();
if (confirm(t("budget.deleteTemplateConfirm"))) {
onDelete(templateId);
}
};
return (
<div className="flex items-center gap-2">
{/* Apply template */}
<div ref={applyRef} className="relative">
<button
onClick={() => { setShowApply(!showApply); setShowSave(false); }}
disabled={disabled}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
<BookTemplate size={16} />
{t("budget.applyTemplate")}
</button>
{showApply && (
<div className="absolute right-0 top-full mt-1 z-40 w-64 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg py-1">
{templates.length === 0 ? (
<p className="px-4 py-3 text-sm text-[var(--muted-foreground)]">
{t("budget.noTemplates")}
</p>
) : (
templates.map((tmpl) => (
<div
key={tmpl.id}
className="flex items-center justify-between px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors"
onClick={() => { onApply(tmpl.id); setShowApply(false); }}
>
<span className="text-sm truncate">{tmpl.name}</span>
<button
onClick={(e) => handleDelete(e, tmpl.id)}
className="shrink-0 text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors ml-2"
>
<Trash2 size={14} />
</button>
</div>
))
)}
</div>
)}
</div>
{/* Save as template */}
<div ref={saveRef} className="relative">
<button
onClick={() => { setShowSave(!showSave); setShowApply(false); }}
disabled={disabled}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
<Save size={16} />
{t("budget.saveAsTemplate")}
</button>
{showSave && (
<div className="absolute right-0 top-full mt-1 z-40 w-72 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg p-4">
<label className="block text-sm font-medium mb-1.5">
{t("budget.templateName")}
</label>
<input
type="text"
value={templateName}
onChange={(e) => setTemplateName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleSave(); }}
placeholder={t("budget.templateName")}
className="w-full bg-[var(--background)] border border-[var(--border)] rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-1 focus:ring-[var(--primary)] mb-3"
autoFocus
/>
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowSave(false); setTemplateName(""); }}
className="px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleSave}
disabled={!templateName.trim()}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50"
>
{t("common.save")}
</button>
</div>
</div>
)}
</div>
</div>
);
}

295
src/hooks/useAdjustments.ts Normal file
View file

@ -0,0 +1,295 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { Adjustment, Category } from "../shared/types";
import type { AdjustmentEntryWithCategory } from "../services/adjustmentService";
import {
getAllAdjustments,
getEntriesByAdjustmentId,
createAdjustment,
updateAdjustment,
deleteAdjustment as deleteAdj,
createEntry,
updateEntry,
deleteEntry,
} from "../services/adjustmentService";
import { getDb } from "../services/db";
export interface AdjustmentFormData {
name: string;
description: string;
date: string;
is_recurring: boolean;
}
export interface EntryFormData {
id?: number;
category_id: number;
amount: number;
description: string;
}
interface AdjustmentsState {
adjustments: Adjustment[];
selectedAdjustmentId: number | null;
entries: AdjustmentEntryWithCategory[];
categories: Category[];
editingAdjustment: AdjustmentFormData | null;
editingEntries: EntryFormData[];
isCreating: boolean;
isLoading: boolean;
isSaving: boolean;
error: string | null;
}
type AdjustmentsAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_ADJUSTMENTS"; payload: Adjustment[] }
| { type: "SET_CATEGORIES"; payload: Category[] }
| { type: "SELECT_ADJUSTMENT"; payload: number | null }
| { type: "SET_ENTRIES"; payload: AdjustmentEntryWithCategory[] }
| { type: "START_CREATING" }
| { type: "START_EDITING"; payload: { adjustment: AdjustmentFormData; entries: EntryFormData[] } }
| { type: "CANCEL_EDITING" }
| { type: "SET_EDITING_ENTRIES"; payload: EntryFormData[] };
const initialState: AdjustmentsState = {
adjustments: [],
selectedAdjustmentId: null,
entries: [],
categories: [],
editingAdjustment: null,
editingEntries: [],
isCreating: false,
isLoading: false,
isSaving: false,
error: null,
};
function reducer(state: AdjustmentsState, action: AdjustmentsAction): AdjustmentsState {
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_ADJUSTMENTS":
return { ...state, adjustments: action.payload, isLoading: false };
case "SET_CATEGORIES":
return { ...state, categories: action.payload };
case "SELECT_ADJUSTMENT":
return {
...state,
selectedAdjustmentId: action.payload,
editingAdjustment: null,
editingEntries: [],
isCreating: false,
entries: [],
};
case "SET_ENTRIES":
return { ...state, entries: action.payload };
case "START_CREATING":
return {
...state,
isCreating: true,
selectedAdjustmentId: null,
entries: [],
editingAdjustment: {
name: "",
description: "",
date: new Date().toISOString().slice(0, 10),
is_recurring: false,
},
editingEntries: [],
};
case "START_EDITING":
return {
...state,
isCreating: false,
editingAdjustment: action.payload.adjustment,
editingEntries: action.payload.entries,
};
case "CANCEL_EDITING":
return { ...state, editingAdjustment: null, editingEntries: [], isCreating: false };
case "SET_EDITING_ENTRIES":
return { ...state, editingEntries: action.payload };
default:
return state;
}
}
export function useAdjustments() {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const loadCategories = useCallback(async () => {
try {
const db = await getDb();
const rows = await db.select<Category[]>(
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
);
dispatch({ type: "SET_CATEGORIES", payload: rows });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
const loadAdjustments = useCallback(async () => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const rows = await getAllAdjustments();
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ADJUSTMENTS", payload: rows });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
}, []);
useEffect(() => {
loadAdjustments();
loadCategories();
}, [loadAdjustments, loadCategories]);
const selectAdjustment = useCallback(async (id: number | null) => {
dispatch({ type: "SELECT_ADJUSTMENT", payload: id });
if (id !== null) {
try {
const entries = await getEntriesByAdjustmentId(id);
dispatch({ type: "SET_ENTRIES", payload: entries });
} 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 adj = state.adjustments.find((a) => a.id === state.selectedAdjustmentId);
if (!adj) return;
dispatch({
type: "START_EDITING",
payload: {
adjustment: {
name: adj.name,
description: adj.description ?? "",
date: adj.date,
is_recurring: adj.is_recurring,
},
entries: state.entries.map((e) => ({
id: e.id,
category_id: e.category_id,
amount: e.amount,
description: e.description ?? "",
})),
},
});
}, [state.adjustments, state.selectedAdjustmentId, state.entries]);
const cancelEditing = useCallback(() => {
dispatch({ type: "CANCEL_EDITING" });
}, []);
const saveAdjustment = useCallback(
async (formData: AdjustmentFormData, entries: EntryFormData[]) => {
dispatch({ type: "SET_SAVING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
if (state.isCreating) {
const newId = await createAdjustment({
name: formData.name,
description: formData.description || undefined,
date: formData.date,
is_recurring: formData.is_recurring,
});
for (const entry of entries) {
await createEntry({
adjustment_id: newId,
category_id: entry.category_id,
amount: entry.amount,
description: entry.description || undefined,
});
}
await loadAdjustments();
await selectAdjustment(newId);
} else if (state.selectedAdjustmentId !== null) {
await updateAdjustment(state.selectedAdjustmentId, {
name: formData.name,
description: formData.description || undefined,
date: formData.date,
is_recurring: formData.is_recurring,
});
// Determine which entries to create, update, or delete
const existingIds = new Set(state.entries.map((e) => e.id));
const keptIds = new Set<number>();
for (const entry of entries) {
if (entry.id && existingIds.has(entry.id)) {
await updateEntry(entry.id, {
category_id: entry.category_id,
amount: entry.amount,
description: entry.description || undefined,
});
keptIds.add(entry.id);
} else {
await createEntry({
adjustment_id: state.selectedAdjustmentId,
category_id: entry.category_id,
amount: entry.amount,
description: entry.description || undefined,
});
}
}
// Delete removed entries
for (const id of existingIds) {
if (!keptIds.has(id)) {
await deleteEntry(id);
}
}
await loadAdjustments();
await selectAdjustment(state.selectedAdjustmentId);
}
dispatch({ type: "SET_SAVING", payload: false });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[state.isCreating, state.selectedAdjustmentId, state.entries, loadAdjustments, selectAdjustment]
);
const removeAdjustment = useCallback(
async (id: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteAdj(id);
dispatch({ type: "SELECT_ADJUSTMENT", payload: null });
await loadAdjustments();
dispatch({ type: "SET_SAVING", payload: false });
} catch (e) {
dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) });
}
},
[loadAdjustments]
);
return {
state,
selectAdjustment,
startCreating,
startEditing,
cancelEditing,
saveAdjustment,
deleteAdjustment: removeAdjustment,
};
}

251
src/hooks/useBudget.ts Normal file
View file

@ -0,0 +1,251 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetRow, BudgetTemplate } from "../shared/types";
import {
getActiveCategories,
getBudgetEntriesForMonth,
getActualsByCategory,
upsertBudgetEntry,
deleteBudgetEntry,
getAllTemplates,
saveAsTemplate as saveAsTemplateSvc,
applyTemplate as applyTemplateSvc,
deleteTemplate as deleteTemplateSvc,
} from "../services/budgetService";
interface BudgetState {
year: number;
month: number;
rows: BudgetRow[];
templates: BudgetTemplate[];
isLoading: boolean;
isSaving: boolean;
error: string | null;
}
type BudgetAction =
| { type: "SET_LOADING"; payload: boolean }
| { type: "SET_SAVING"; payload: boolean }
| { type: "SET_ERROR"; payload: string | null }
| { type: "SET_DATA"; payload: { rows: BudgetRow[]; templates: BudgetTemplate[] } }
| { type: "NAVIGATE_MONTH"; payload: { year: number; month: number } };
function initialState(): BudgetState {
const now = new Date();
return {
year: now.getFullYear(),
month: now.getMonth() + 1,
rows: [],
templates: [],
isLoading: false,
isSaving: false,
error: null,
};
}
function reducer(state: BudgetState, action: BudgetAction): BudgetState {
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_DATA":
return {
...state,
rows: action.payload.rows,
templates: action.payload.templates,
isLoading: false,
};
case "NAVIGATE_MONTH":
return { ...state, year: action.payload.year, month: action.payload.month };
default:
return state;
}
}
const TYPE_ORDER: Record<string, number> = { expense: 0, income: 1, transfer: 2 };
export function useBudget() {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number, month: number) => {
const fetchId = ++fetchIdRef.current;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const [categories, entries, actuals, templates] = await Promise.all([
getActiveCategories(),
getBudgetEntriesForMonth(year, month),
getActualsByCategory(year, month),
getAllTemplates(),
]);
if (fetchId !== fetchIdRef.current) return;
const entryMap = new Map(entries.map((e) => [e.category_id, e]));
const actualMap = new Map(actuals.map((a) => [a.category_id, a.actual]));
const rows: BudgetRow[] = categories.map((cat) => {
const entry = entryMap.get(cat.id);
const planned = entry?.amount ?? 0;
const actual = actualMap.get(cat.id) ?? 0;
let difference: number;
if (cat.type === "income") {
difference = actual - planned;
} else {
difference = planned - Math.abs(actual);
}
return {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
planned,
actual,
difference,
notes: entry?.notes,
};
});
rows.sort((a, b) => {
const typeA = TYPE_ORDER[a.category_type] ?? 9;
const typeB = TYPE_ORDER[b.category_type] ?? 9;
if (typeA !== typeB) return typeA - typeB;
return a.category_name.localeCompare(b.category_name);
});
dispatch({ type: "SET_DATA", payload: { rows, templates } });
} catch (e) {
if (fetchId !== fetchIdRef.current) return;
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, []);
useEffect(() => {
refreshData(state.year, state.month);
}, [state.year, state.month, refreshData]);
const navigateMonth = useCallback((delta: -1 | 1) => {
let newMonth = state.month + delta;
let newYear = state.year;
if (newMonth < 1) {
newMonth = 12;
newYear--;
} else if (newMonth > 12) {
newMonth = 1;
newYear++;
}
dispatch({ type: "NAVIGATE_MONTH", payload: { year: newYear, month: newMonth } });
}, [state.year, state.month]);
const updatePlanned = useCallback(
async (categoryId: number, amount: number, notes?: string) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await upsertBudgetEntry(categoryId, state.year, state.month, amount, notes);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
const removePlanned = useCallback(
async (categoryId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteBudgetEntry(categoryId, state.year, state.month);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
const saveTemplate = useCallback(
async (name: string, description?: string) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
const entries = state.rows
.filter((r) => r.planned !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.planned }));
await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.rows, state.year, state.month, refreshData]
);
const applyTemplate = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await applyTemplateSvc(templateId, state.year, state.month);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
const deleteTemplate = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteTemplateSvc(templateId);
await refreshData(state.year, state.month);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
);
return {
state,
navigateMonth,
updatePlanned,
removePlanned,
saveTemplate,
applyTemplate,
deleteTemplate,
};
}

View file

@ -256,6 +256,15 @@
"description": "Description",
"amount": "Amount",
"recurring": "Recurring",
"entries": "Entries",
"addEntry": "Add entry",
"newAdjustment": "New adjustment",
"editAdjustment": "Edit adjustment",
"deleteConfirm": "Delete this adjustment?",
"total": "Total",
"selectAdjustment": "Select an adjustment",
"category": "Category",
"noEntries": "No entries yet",
"help": {
"title": "How to use Adjustments",
"tips": [
@ -267,18 +276,31 @@
},
"budget": {
"title": "Budget",
"month": "Month",
"year": "Year",
"category": "Category",
"planned": "Planned",
"actual": "Actual",
"difference": "Difference",
"template": "Template",
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers",
"totalPlanned": "Total Planned",
"totalActual": "Total Actual",
"totalDifference": "Difference",
"noCategories": "No categories found. Create categories first to set up your budget.",
"saveAsTemplate": "Save as template",
"applyTemplate": "Apply template",
"noTemplates": "No templates saved yet.",
"templateName": "Template name",
"templateDescription": "Description (optional)",
"deleteTemplateConfirm": "Delete this template?",
"help": {
"title": "How to use Budget",
"tips": [
"Set planned amounts for each category to track your spending goals",
"Compare planned vs. actual spending to see where you're over or under budget",
"Use templates to quickly apply the same budget across multiple months"
"Use the month navigator to switch between months",
"Click on a planned amount to edit it inline — press Enter to save or Escape to cancel",
"The actual column shows real spending from your imported transactions",
"Green means under budget, red means over budget",
"Save your budget as a template and apply it to other months quickly"
]
}
},

View file

@ -256,6 +256,15 @@
"description": "Description",
"amount": "Montant",
"recurring": "Récurrent",
"entries": "Entrées",
"addEntry": "Ajouter une entrée",
"newAdjustment": "Nouvel ajustement",
"editAdjustment": "Modifier l'ajustement",
"deleteConfirm": "Supprimer cet ajustement ?",
"total": "Total",
"selectAdjustment": "Sélectionnez un ajustement",
"category": "Catégorie",
"noEntries": "Aucune entrée",
"help": {
"title": "Comment utiliser les Ajustements",
"tips": [
@ -267,18 +276,31 @@
},
"budget": {
"title": "Budget",
"month": "Mois",
"year": "Année",
"category": "Catégorie",
"planned": "Prévu",
"actual": "Réel",
"difference": "Écart",
"template": "Modèle",
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts",
"totalPlanned": "Total prévu",
"totalActual": "Total réel",
"totalDifference": "Écart",
"noCategories": "Aucune catégorie trouvée. Créez des catégories pour configurer votre budget.",
"saveAsTemplate": "Sauvegarder comme modèle",
"applyTemplate": "Appliquer un modèle",
"noTemplates": "Aucun modèle enregistré.",
"templateName": "Nom du modèle",
"templateDescription": "Description (optionnel)",
"deleteTemplateConfirm": "Supprimer ce modèle ?",
"help": {
"title": "Comment utiliser le Budget",
"tips": [
"Définissez des montants prévus par catégorie pour suivre vos objectifs de dépenses",
"Comparez le prévu et le réel pour voir où vous dépassez ou êtes en dessous du budget",
"Utilisez les modèles pour appliquer rapidement le même budget sur plusieurs mois"
"Utilisez le navigateur de mois pour passer d'un mois à l'autre",
"Cliquez sur un montant prévu pour le modifier — appuyez sur Entrée pour sauvegarder ou Échap pour annuler",
"La colonne réel affiche les dépenses réelles de vos transactions importées",
"Vert signifie sous le budget, rouge signifie au-dessus du budget",
"Sauvegardez votre budget comme modèle et appliquez-le rapidement à d'autres mois"
]
}
},

View file

@ -1,18 +1,102 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import { useAdjustments } from "../hooks/useAdjustments";
import { getEntriesByAdjustmentId } from "../services/adjustmentService";
import type { AdjustmentEntryWithCategory } from "../services/adjustmentService";
import AdjustmentListPanel from "../components/adjustments/AdjustmentListPanel";
import AdjustmentDetailPanel from "../components/adjustments/AdjustmentDetailPanel";
export default function AdjustmentsPage() {
const { t } = useTranslation();
const {
state,
selectAdjustment,
startCreating,
startEditing,
cancelEditing,
saveAdjustment,
deleteAdjustment,
} = useAdjustments();
const [entriesMap, setEntriesMap] = useState<Map<number, AdjustmentEntryWithCategory[]>>(
new Map()
);
const loadAllEntries = useCallback(async () => {
const map = new Map<number, AdjustmentEntryWithCategory[]>();
for (const adj of state.adjustments) {
try {
const entries = await getEntriesByAdjustmentId(adj.id);
map.set(adj.id, entries);
} catch {
// skip on error
}
}
setEntriesMap(map);
}, [state.adjustments]);
useEffect(() => {
if (state.adjustments.length > 0) {
loadAllEntries();
}
}, [state.adjustments, loadAllEntries]);
const selectedAdjustment =
state.selectedAdjustmentId !== null
? state.adjustments.find((a) => a.id === state.selectedAdjustmentId) ?? null
: null;
return (
<div>
<div className="relative flex items-center gap-3 mb-6">
<div className="relative flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("adjustments.title")}</h1>
<PageHelp helpKey="adjustments" />
</div>
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("common.noResults")}</p>
<button
onClick={startCreating}
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-[var(--primary)] text-white text-sm font-medium hover:opacity-90"
>
<Plus size={16} />
{t("adjustments.newAdjustment")}
</button>
</div>
{state.error && (
<div className="mb-4 p-3 rounded-lg bg-[var(--negative)]/10 text-[var(--negative)] text-sm">
{state.error}
</div>
)}
{state.isLoading ? (
<p className="text-[var(--muted-foreground)]">{t("common.loading")}</p>
) : (
<div className="flex gap-6" style={{ minHeight: "calc(100vh - 180px)" }}>
<div className="w-1/3 bg-[var(--card)] rounded-xl border border-[var(--border)] p-3 overflow-y-auto">
<AdjustmentListPanel
adjustments={state.adjustments}
selectedId={state.selectedAdjustmentId}
onSelect={selectAdjustment}
entriesByAdjustment={entriesMap}
/>
</div>
<AdjustmentDetailPanel
selectedAdjustment={selectedAdjustment}
entries={state.entries}
categories={state.categories}
editingAdjustment={state.editingAdjustment}
editingEntries={state.editingEntries}
isCreating={state.isCreating}
isSaving={state.isSaving}
onStartEditing={startEditing}
onCancelEditing={cancelEditing}
onSave={saveAdjustment}
onDelete={deleteAdjustment}
/>
</div>
)}
</div>
);
}

View file

@ -1,18 +1,51 @@
import { useTranslation } from "react-i18next";
import { PageHelp } from "../components/shared/PageHelp";
import { useBudget } from "../hooks/useBudget";
import MonthNavigator from "../components/budget/MonthNavigator";
import BudgetSummaryCards from "../components/budget/BudgetSummaryCards";
import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions";
export default function BudgetPage() {
const { t } = useTranslation();
const {
state,
navigateMonth,
updatePlanned,
saveTemplate,
applyTemplate,
deleteTemplate,
} = useBudget();
const { year, month, rows, templates, isLoading, isSaving, error } = state;
return (
<div>
<div className="relative flex items-center gap-3 mb-6">
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{t("budget.title")}</h1>
<PageHelp helpKey="budget" />
</div>
<div className="bg-[var(--card)] rounded-xl p-8 border border-[var(--border)] text-center text-[var(--muted-foreground)]">
<p>{t("common.noResults")}</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3">
<TemplateActions
templates={templates}
onApply={applyTemplate}
onSave={saveTemplate}
onDelete={deleteTemplate}
disabled={isSaving}
/>
<MonthNavigator year={year} month={month} onNavigate={navigateMonth} />
</div>
</div>
{error && (
<div className="mb-4 p-3 rounded-lg bg-red-100 text-red-800 text-sm border border-red-200">
{error}
</div>
)}
<BudgetSummaryCards rows={rows} />
<BudgetTable rows={rows} onUpdatePlanned={updatePlanned} />
</div>
);
}

View file

@ -0,0 +1,102 @@
import { getDb } from "./db";
import type { Adjustment, AdjustmentEntry } from "../shared/types";
export type AdjustmentEntryWithCategory = AdjustmentEntry & {
category_name: string;
category_color: string;
};
export async function getAllAdjustments(): Promise<Adjustment[]> {
const db = await getDb();
return db.select<Adjustment[]>(
"SELECT * FROM adjustments ORDER BY date DESC"
);
}
export async function getAdjustmentById(
id: number
): Promise<Adjustment | null> {
const db = await getDb();
const rows = await db.select<Adjustment[]>(
"SELECT * FROM adjustments WHERE id = $1",
[id]
);
return rows.length > 0 ? rows[0] : null;
}
export async function createAdjustment(data: {
name: string;
description?: string;
date: string;
is_recurring: boolean;
}): Promise<number> {
const db = await getDb();
const result = await db.execute(
`INSERT INTO adjustments (name, description, date, is_recurring)
VALUES ($1, $2, $3, $4)`,
[data.name, data.description || null, data.date, data.is_recurring ? 1 : 0]
);
return result.lastInsertId as number;
}
export async function updateAdjustment(
id: number,
data: { name: string; description?: string; date: string; is_recurring: boolean }
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE adjustments SET name = $1, description = $2, date = $3, is_recurring = $4, updated_at = CURRENT_TIMESTAMP
WHERE id = $5`,
[data.name, data.description || null, data.date, data.is_recurring ? 1 : 0, id]
);
}
export async function deleteAdjustment(id: number): Promise<void> {
const db = await getDb();
await db.execute("DELETE FROM adjustments WHERE id = $1", [id]);
}
export async function getEntriesByAdjustmentId(
adjustmentId: number
): Promise<AdjustmentEntryWithCategory[]> {
const db = await getDb();
return db.select<AdjustmentEntryWithCategory[]>(
`SELECT ae.*, c.name AS category_name, COALESCE(c.color, '#9ca3af') AS category_color
FROM adjustment_entries ae
JOIN categories c ON c.id = ae.category_id
WHERE ae.adjustment_id = $1
ORDER BY ae.id`,
[adjustmentId]
);
}
export async function createEntry(entry: {
adjustment_id: number;
category_id: number;
amount: number;
description?: string;
}): Promise<number> {
const db = await getDb();
const result = await db.execute(
`INSERT INTO adjustment_entries (adjustment_id, category_id, amount, description)
VALUES ($1, $2, $3, $4)`,
[entry.adjustment_id, entry.category_id, entry.amount, entry.description || null]
);
return result.lastInsertId as number;
}
export async function updateEntry(
id: number,
data: { category_id: number; amount: number; description?: string }
): Promise<void> {
const db = await getDb();
await db.execute(
`UPDATE adjustment_entries SET category_id = $1, amount = $2, description = $3 WHERE id = $4`,
[data.category_id, data.amount, data.description || null, id]
);
}
export async function deleteEntry(id: number): Promise<void> {
const db = await getDb();
await db.execute("DELETE FROM adjustment_entries WHERE id = $1", [id]);
}

View file

@ -0,0 +1,136 @@
import { getDb } from "./db";
import type {
Category,
BudgetEntry,
BudgetTemplate,
BudgetTemplateEntry,
} from "../shared/types";
function computeMonthDateRange(year: number, month: number) {
const dateFrom = `${year}-${String(month).padStart(2, "0")}-01`;
const lastDay = new Date(year, month, 0).getDate();
const dateTo = `${year}-${String(month).padStart(2, "0")}-${String(lastDay).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export async function getActiveCategories(): Promise<Category[]> {
const db = await getDb();
return db.select<Category[]>(
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
);
}
export async function getBudgetEntriesForMonth(
year: number,
month: number
): Promise<BudgetEntry[]> {
const db = await getDb();
return db.select<BudgetEntry[]>(
"SELECT * FROM budget_entries WHERE year = $1 AND month = $2",
[year, month]
);
}
export async function upsertBudgetEntry(
categoryId: number,
year: number,
month: number,
amount: number,
notes?: string
): Promise<void> {
const db = await getDb();
await db.execute(
`INSERT INTO budget_entries (category_id, year, month, amount, notes)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, notes = $5, updated_at = CURRENT_TIMESTAMP`,
[categoryId, year, month, amount, notes || null]
);
}
export async function deleteBudgetEntry(
categoryId: number,
year: number,
month: number
): Promise<void> {
const db = await getDb();
await db.execute(
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
[categoryId, year, month]
);
}
export async function getActualsByCategory(
year: number,
month: number
): Promise<Array<{ category_id: number | null; actual: number }>> {
const db = await getDb();
const { dateFrom, dateTo } = computeMonthDateRange(year, month);
return db.select<Array<{ category_id: number | null; actual: number }>>(
`SELECT category_id, COALESCE(SUM(amount), 0) AS actual
FROM transactions
WHERE date BETWEEN $1 AND $2
GROUP BY category_id`,
[dateFrom, dateTo]
);
}
// Templates
export async function getAllTemplates(): Promise<BudgetTemplate[]> {
const db = await getDb();
return db.select<BudgetTemplate[]>(
"SELECT * FROM budget_templates ORDER BY name"
);
}
export async function getTemplateEntries(
templateId: number
): Promise<BudgetTemplateEntry[]> {
const db = await getDb();
return db.select<BudgetTemplateEntry[]>(
"SELECT * FROM budget_template_entries WHERE template_id = $1",
[templateId]
);
}
export async function saveAsTemplate(
name: string,
description: string | undefined,
entries: Array<{ category_id: number; amount: number }>
): Promise<number> {
const db = await getDb();
const result = await db.execute(
"INSERT INTO budget_templates (name, description) VALUES ($1, $2)",
[name, description || null]
);
const templateId = result.lastInsertId as number;
for (const entry of entries) {
await db.execute(
"INSERT INTO budget_template_entries (template_id, category_id, amount) VALUES ($1, $2, $3)",
[templateId, entry.category_id, entry.amount]
);
}
return templateId;
}
export async function applyTemplate(
templateId: number,
year: number,
month: number
): Promise<void> {
const entries = await getTemplateEntries(templateId);
for (const entry of entries) {
await upsertBudgetEntry(entry.category_id, year, month, entry.amount);
}
}
export async function deleteTemplate(templateId: number): Promise<void> {
const db = await getDb();
await db.execute(
"DELETE FROM budget_template_entries WHERE template_id = $1",
[templateId]
);
await db.execute("DELETE FROM budget_templates WHERE id = $1", [templateId]);
}

View file

@ -119,6 +119,17 @@ export interface BudgetTemplateEntry {
amount: number;
}
export interface BudgetRow {
category_id: number;
category_name: string;
category_color: string;
category_type: "expense" | "income" | "transfer";
planned: number;
actual: number;
difference: number;
notes?: string;
}
export interface UserPreference {
key: string;
value: string;

37
tasks/lessons.md Normal file
View file

@ -0,0 +1,37 @@
# Lessons Learned
## 2026-02-10 - NSIS / Tauri productName
**Mistake**: Used apostrophe in `productName` (`Simpl'Résultat`) which broke the NSIS installer script on Windows CI
**Pattern**: NSIS uses single quotes as string delimiters — special characters in `productName` get interpolated into NSIS scripts and can break the build
**Rule**: Keep `productName` in `tauri.conf.json` free of apostrophes and other shell/script-special characters. Use `app.windows[].title` for the display name with special characters.
**Applied**: Tauri v2 Windows builds, any NSIS-based packaging
## 2026-02-10 - GitHub Actions GITHUB_TOKEN permissions
**Mistake**: Workflow failed with `Resource not accessible by integration` when tauri-action tried to create a GitHub Release
**Pattern**: By default, `GITHUB_TOKEN` has read-only `contents` permission in newer repos / org settings. Creating releases requires write access.
**Rule**: Always add `permissions: contents: write` at the top level of any GitHub Actions workflow that creates releases or pushes tags/artifacts.
**Applied**: Any workflow using `tauri-apps/tauri-action`, `softprops/action-gh-release`, or direct GitHub Release API calls
## 2026-02-11 - Write tool requires Read first
**Mistake**: Tried to overwrite the plan file with the Write tool without reading it first, causing an error: "File has not been read yet. Read it first before writing to it."
**Pattern**: The Write tool enforces a safety check — you must Read a file at least once in the conversation before you can Write to it. This prevents accidental overwrites of files you haven't seen.
**Rule**: Always Read a file before using Write on it, even if you intend to completely replace its contents. This applies to plan files, config files, and any existing file.
**Applied**: All file editing workflows, especially plan mode where you repeatedly update the plan file
## 2026-02-11 - CSV preprocessing must be applied everywhere
**Mistake**: `preprocessQuotedCSV()` was only called in `parsePreview`, not in `loadHeadersWithConfig`. Desjardins CSVs parsed as single-column in header loading.
**Pattern**: When a preprocessing step is needed for data parsing, it must be applied at EVERY code path that parses the same data — not just the main one.
**Rule**: When adding a preprocessing step, grep for all call sites that parse the same data format and apply the step consistently.
**Applied**: CSV import, any file format with a normalization/preprocessing layer
## 2026-02-11 - Synthetic headers needed for no-header CSVs in all dispatches
**Mistake**: `parsePreview` left `headers = []` when `hasHeader: false`, overwriting previously loaded synthetic headers. Column mapping editor disappeared on back-navigation.
**Pattern**: State dispatches can overwrite previous state. If a component depends on state set by an earlier step, later dispatches must preserve or regenerate that state.
**Rule**: When dispatching state updates that include derived data (like headers), always populate ALL fields — don't leave arrays empty assuming they'll persist from a previous dispatch.
**Applied**: Wizard-style multi-step UIs with back-navigation
## 2026-02-11 - Constant numeric columns are identifiers, not amounts
**Mistake**: Account number and transit number columns passed numeric detection because they contain valid numbers, but they're identifiers not amounts.
**Pattern**: Identifier columns have very low cardinality relative to row count (often a single repeated value). Amount columns vary per transaction.
**Rule**: When detecting "amount" columns in CSV auto-detect, exclude numeric columns with ≤1 distinct value. Also treat 0 as "empty" in sparse-complementary detection for debit/credit pairs.
**Applied**: CSV auto-detection, any heuristic column type inference

29
tasks/todo.md Normal file
View file

@ -0,0 +1,29 @@
# Task: Fix 3 Desjardins CSV Import Bugs
## Root Cause Analysis
### Bug 1: No-header columns show "0: Col 0" for every column
**Root cause:** `loadHeadersWithConfig` doesn't call `preprocessQuotedCSV()` before parsing. For Desjardins-style quoted CSVs, PapaParse sees each line as a single column. So `firstDataRow` has only 1 element → generates only `["Col 0"]`.
### Bug 2: Going back from preview loses column mapping
**Root cause:** `parsePreview` only populates `headers` when `hasHeader: true`. When `hasHeader: false`, it leaves `headers = []`, overwriting `previewHeaders`. On source-config, `{headers.length > 0 && <ColumnMappingEditor />}` renders nothing.
### Bug 3: Auto-detect amount picks account number column
**Root cause:** Constant numeric columns (account number, transit number) pass the "≥50% numeric" check. If the debit/credit columns have 0 instead of empty, `isSparseComplementary` fails. Falls back to first numeric candidate = account number.
## Plan
- [x] Bug 1: Add `preprocessQuotedCSV()` in `loadHeadersWithConfig` before PapaParse
- [x] Bug 2: In `parsePreview`, generate synthetic headers when `hasHeader: false`
- [x] Bug 3a: Exclude constant-value numeric columns from amount candidates
- [x] Bug 3b: Treat 0 values as empty in `isSparseComplementary`
- [x] Build verification
- [x] Update lessons.md
## Progress Notes
- Bug 1: Added `preprocessQuotedCSV(preview)` call in `loadHeadersWithConfig` (useImportWizard.ts:341)
- Bug 2: Added else-if branch in `parsePreview` to generate `Col N` headers when hasHeader is false (useImportWizard.ts:450-453)
- Bug 3a: Added `distinctValues` tracking in `detectNumericColumns`, skip columns with ≤1 distinct value and >2 rows (csvAutoDetect.ts:227-236)
- Bug 3b: Changed `isSparseComplementary` to parse values and treat `0` as empty (csvAutoDetect.ts:452-455)
## Review
Build passes. 2 files changed: useImportWizard.ts, csvAutoDetect.ts. All fixes are minimal and targeted.