feat: add Budget and Adjustments pages with full functionality
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
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:
parent
474c7b947a
commit
5f5696c29a
19 changed files with 2017 additions and 25 deletions
105
masterplan.md
Normal file
105
masterplan.md
Normal 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 |
|
||||
187
src/components/adjustments/AdjustmentDetailPanel.tsx
Normal file
187
src/components/adjustments/AdjustmentDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
src/components/adjustments/AdjustmentForm.tsx
Normal file
186
src/components/adjustments/AdjustmentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
69
src/components/adjustments/AdjustmentListPanel.tsx
Normal file
69
src/components/adjustments/AdjustmentListPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
src/components/budget/BudgetSummaryCards.tsx
Normal file
58
src/components/budget/BudgetSummaryCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
src/components/budget/BudgetTable.tsx
Normal file
190
src/components/budget/BudgetTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
src/components/budget/MonthNavigator.tsx
Normal file
36
src/components/budget/MonthNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
src/components/budget/TemplateActions.tsx
Normal file
139
src/components/budget/TemplateActions.tsx
Normal 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
295
src/hooks/useAdjustments.ts
Normal 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
251
src/hooks/useBudget.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
102
src/services/adjustmentService.ts
Normal file
102
src/services/adjustmentService.ts
Normal 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]);
|
||||
}
|
||||
136
src/services/budgetService.ts
Normal file
136
src/services/budgetService.ts
Normal 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]);
|
||||
}
|
||||
|
|
@ -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
37
tasks/lessons.md
Normal 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
29
tasks/todo.md
Normal 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.
|
||||
Loading…
Reference in a new issue