feat: 12-month budget grid, import UX improvements, confirmation dialogs (v0.2.4)
Some checks failed
Release / build (windows-latest) (push) Has been cancelled

- Budget page: replace single-month view with 12-month annual grid
  with inline editing, split-evenly button, and year navigation
- Import: pre-select only new files and sort them first, show
  "already imported" badge on previously imported files
- Add confirmation modals for category re-initialization and
  import deletion (single and bulk), replacing native confirm()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-14 12:59:11 +00:00
parent 29a1a15120
commit 720f52bad6
18 changed files with 561 additions and 223 deletions

View file

@ -1,7 +1,7 @@
{
"name": "simpl_result_scaffold",
"private": true,
"version": "0.2.3",
"version": "0.2.4",
"type": "module",
"scripts": {
"dev": "vite",

View file

@ -1,6 +1,6 @@
[package]
name = "simpl-result"
version = "0.2.3"
version = "0.2.4"
description = "Personal finance management app"
authors = ["you"]
edition = "2021"

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Résultat",
"version": "0.2.3",
"version": "0.2.4",
"identifier": "com.simpl.resultat",
"build": {
"beforeDevCommand": "npm run dev",

View file

@ -1,50 +1,79 @@
import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next";
import type { BudgetRow } from "../../shared/types";
import { SplitSquareHorizontal } from "lucide-react";
import type { BudgetYearRow } from "../../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
const fmt = new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
const MONTH_KEYS = [
"months.jan", "months.feb", "months.mar", "months.apr",
"months.may", "months.jun", "months.jul", "months.aug",
"months.sep", "months.oct", "months.nov", "months.dec",
] as const;
interface BudgetTableProps {
rows: BudgetRow[];
onUpdatePlanned: (categoryId: number, amount: number) => void;
rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, month: number, amount: number) => void;
onSplitEvenly: (categoryId: number, annualAmount: number) => void;
}
export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps) {
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
const { t } = useTranslation();
const [editingCategoryId, setEditingCategoryId] = useState<number | null>(null);
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
const [editingValue, setEditingValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingCategoryId !== null && inputRef.current) {
if (editingCell && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [editingCategoryId]);
}, [editingCell]);
const handleStartEdit = (row: BudgetRow) => {
setEditingCategoryId(row.category_id);
setEditingValue(row.planned === 0 ? "" : String(row.planned));
const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => {
setEditingCell({ categoryId, monthIdx });
setEditingValue(currentValue === 0 ? "" : String(currentValue));
};
const handleSave = () => {
if (editingCategoryId === null) return;
if (!editingCell) return;
const amount = parseFloat(editingValue) || 0;
onUpdatePlanned(editingCategoryId, amount);
setEditingCategoryId(null);
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
setEditingCell(null);
};
const handleCancel = () => {
setEditingCategoryId(null);
setEditingCell(null);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel();
if (e.key === "Tab") {
e.preventDefault();
if (!editingCell) return;
const amount = parseFloat(editingValue) || 0;
onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
// Move to next month cell
const nextMonth = editingCell.monthIdx + (e.shiftKey ? -1 : 1);
if (nextMonth >= 0 && nextMonth < 12) {
const row = rows.find((r) => r.category_id === editingCell.categoryId);
if (row) {
handleStartEdit(editingCell.categoryId, nextMonth, row.months[nextMonth]);
}
} else {
setEditingCell(null);
}
}
};
// Group rows by type
const grouped: Record<string, BudgetRow[]> = {};
const grouped: Record<string, BudgetYearRow[]> = {};
for (const row of rows) {
const key = row.category_type;
if (!grouped[key]) grouped[key] = [];
@ -58,9 +87,17 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
transfer: "budget.transfers",
};
const totalPlanned = rows.reduce((s, r) => s + r.planned, 0);
const totalActual = rows.reduce((s, r) => s + Math.abs(r.actual), 0);
const totalDifference = totalPlanned - totalActual;
// Column totals
const monthTotals: number[] = Array(12).fill(0);
let annualTotal = 0;
for (const row of rows) {
for (let m = 0; m < 12; m++) {
monthTotals[m] += row.months[m];
}
annualTotal += row.annual;
}
const totalCols = 14; // category + annual + 12 months
if (rows.length === 0) {
return (
@ -71,22 +108,21 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
}
return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden">
<table className="w-full text-sm">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table className="w-full text-sm whitespace-nowrap">
<thead>
<tr className="border-b border-[var(--border)]">
<th className="text-left py-3 px-4 font-medium text-[var(--muted-foreground)]">
<th className="text-left py-2.5 px-3 font-medium text-[var(--muted-foreground)] sticky left-0 bg-[var(--card)] z-10 min-w-[140px]">
{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 className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.annual")}
</th>
{MONTH_KEYS.map((key) => (
<th key={key} className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[70px]">
{t(key)}
</th>
))}
</tr>
</thead>
<tbody>
@ -97,8 +133,8 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
<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)]"
colSpan={totalCols}
className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
>
{t(typeLabelKeys[type])}
</td>
@ -106,82 +142,81 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
{group.map((row) => (
<tr
key={row.category_id}
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)] transition-colors"
className="border-b border-[var(--border)] last:border-b-0 hover:bg-[var(--muted)]/50 transition-colors"
>
<td className="py-2.5 px-4">
{/* Category name - sticky */}
<td className="py-2 px-3 sticky left-0 bg-[var(--card)] z-10">
<div className="flex items-center gap-2">
<span
className="w-3 h-3 rounded-full shrink-0"
className="w-2.5 h-2.5 rounded-full shrink-0"
style={{ backgroundColor: row.category_color }}
/>
<span>{row.category_name}</span>
<span className="truncate text-xs">{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 ? (
{/* Annual total + split button */}
<td className="py-2 px-2 text-right">
<div className="flex items-center justify-end gap-1">
<span className="font-medium text-xs">
{row.annual === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
fmt.format(row.planned)
fmt.format(row.annual)
)}
</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>
)}
{row.annual > 0 && (
<button
onClick={() => onSplitEvenly(row.category_id, row.annual)}
className="p-0.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors"
title={t("budget.splitEvenly")}
>
<SplitSquareHorizontal size={13} />
</button>
)}
</div>
</td>
{/* 12 month cells */}
{row.months.map((val, mIdx) => (
<td key={mIdx} className="py-2 px-2 text-right">
{editingCell?.categoryId === row.category_id && editingCell.monthIdx === mIdx ? (
<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-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
/>
) : (
<button
onClick={() => handleStartEdit(row.category_id, mIdx, val)}
className="w-full text-right hover:text-[var(--primary)] transition-colors cursor-text text-xs"
>
{val === 0 ? (
<span className="text-[var(--muted-foreground)]"></span>
) : (
fmt.format(val)
)}
</button>
)}
</td>
))}
</tr>
))}
</Fragment>
);
})}
{/* Totals row */}
<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>
<td className="py-2.5 px-3 sticky left-0 bg-[var(--muted)] z-10 text-xs">{t("common.total")}</td>
<td className="py-2.5 px-2 text-right text-xs">{fmt.format(annualTotal)}</td>
{monthTotals.map((total, mIdx) => (
<td key={mIdx} className="py-2.5 px-2 text-right text-xs">
{fmt.format(total)}
</td>
))}
</tr>
</tbody>
</table>

View file

@ -5,15 +5,23 @@ import type { BudgetTemplate } from "../../shared/types";
interface TemplateActionsProps {
templates: BudgetTemplate[];
onApply: (templateId: number) => void;
onApply: (templateId: number, month: number) => void;
onApplyAllMonths: (templateId: number) => void;
onSave: (name: string, description?: string) => void;
onDelete: (templateId: number) => void;
disabled?: boolean;
}
const MONTH_KEYS = [
"months.jan", "months.feb", "months.mar", "months.apr",
"months.may", "months.jun", "months.jul", "months.aug",
"months.sep", "months.oct", "months.nov", "months.dec",
] as const;
export default function TemplateActions({
templates,
onApply,
onApplyAllMonths,
onSave,
onDelete,
disabled,
@ -22,6 +30,7 @@ export default function TemplateActions({
const [showApply, setShowApply] = useState(false);
const [showSave, setShowSave] = useState(false);
const [templateName, setTemplateName] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState<number | null>(null);
const applyRef = useRef<HTMLDivElement>(null);
const saveRef = useRef<HTMLDivElement>(null);
@ -30,6 +39,7 @@ export default function TemplateActions({
const handler = (e: MouseEvent) => {
if (showApply && applyRef.current && !applyRef.current.contains(e.target as Node)) {
setShowApply(false);
setSelectedTemplate(null);
}
if (showSave && saveRef.current && !saveRef.current.contains(e.target as Node)) {
setShowSave(false);
@ -53,12 +63,30 @@ export default function TemplateActions({
}
};
const handleSelectTemplate = (templateId: number) => {
setSelectedTemplate(templateId);
};
const handleApplyToMonth = (month: number) => {
if (selectedTemplate === null) return;
onApply(selectedTemplate, month);
setShowApply(false);
setSelectedTemplate(null);
};
const handleApplyAll = () => {
if (selectedTemplate === null) return;
onApplyAllMonths(selectedTemplate);
setShowApply(false);
setSelectedTemplate(null);
};
return (
<div className="flex items-center gap-2">
{/* Apply template */}
<div ref={applyRef} className="relative">
<button
onClick={() => { setShowApply(!showApply); setShowSave(false); }}
onClick={() => { setShowApply(!showApply); setShowSave(false); setSelectedTemplate(null); }}
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"
>
@ -66,27 +94,52 @@ export default function TemplateActions({
{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"
<div className="absolute right-0 top-full mt-1 z-40 w-72 bg-[var(--card)] border border-[var(--border)] rounded-xl shadow-lg py-1">
{selectedTemplate === null ? (
// Step 1: Pick a template
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={() => handleSelectTemplate(tmpl.id)}
>
<Trash2 size={14} />
</button>
<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>
))
)
) : (
// Step 2: Pick which month(s) to apply to
<div>
<p className="px-4 py-2 text-xs font-medium text-[var(--muted-foreground)] uppercase tracking-wider">
{t("budget.applyToMonth")}
</p>
<div
className="px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors text-sm font-medium text-[var(--primary)]"
onClick={handleApplyAll}
>
{t("budget.allMonths")}
</div>
))
{MONTH_KEYS.map((key, idx) => (
<div
key={key}
className="px-4 py-1.5 hover:bg-[var(--muted)] cursor-pointer transition-colors text-sm"
onClick={() => handleApplyToMonth(idx + 1)}
>
{t(key)}
</div>
))}
</div>
)}
</div>
)}

View file

@ -0,0 +1,28 @@
import { ChevronLeft, ChevronRight } from "lucide-react";
interface YearNavigatorProps {
year: number;
onNavigate: (delta: -1 | 1) => void;
}
export default function YearNavigator({ year, onNavigate }: YearNavigatorProps) {
return (
<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 year"
>
<ChevronLeft size={18} />
</button>
<span className="min-w-[5rem] text-center font-medium">{year}</span>
<button
onClick={() => onNavigate(1)}
className="p-1.5 rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
aria-label="Next year"
>
<ChevronRight size={18} />
</button>
</div>
);
}

View file

@ -1,5 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Trash2, Inbox } from "lucide-react";
import { Trash2, Inbox, AlertTriangle } from "lucide-react";
import { useImportHistory } from "../../hooks/useImportHistory";
interface ImportHistoryPanelProps {
@ -11,6 +12,8 @@ export default function ImportHistoryPanel({
}: ImportHistoryPanelProps) {
const { t } = useTranslation();
const { state, handleDelete, handleDeleteAll } = useImportHistory(onChanged);
const [confirmDelete, setConfirmDelete] = useState<{ id: number; filename: string; rowCount: number } | null>(null);
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
return (
<div className="mt-8">
@ -20,7 +23,7 @@ export default function ImportHistoryPanel({
</h2>
{state.files.length > 0 && (
<button
onClick={handleDeleteAll}
onClick={() => setConfirmDeleteAll(true)}
disabled={state.isDeleting}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50"
>
@ -99,7 +102,7 @@ export default function ImportHistoryPanel({
</td>
<td className="px-4 py-2">
<button
onClick={() => handleDelete(file.id, file.row_count)}
onClick={() => setConfirmDelete({ id: file.id, filename: file.filename, rowCount: file.row_count })}
disabled={state.isDeleting}
className="p-1 rounded hover:bg-[var(--muted)] text-[var(--negative)] disabled:opacity-50"
title={t("common.delete")}
@ -113,6 +116,77 @@ export default function ImportHistoryPanel({
</table>
</div>
)}
{/* Confirm delete single import */}
{confirmDelete && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-full bg-[var(--negative)]/10">
<AlertTriangle size={20} className="text-[var(--negative)]" />
</div>
<h2 className="text-lg font-semibold">{t("common.delete")}</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-1">
<span className="font-medium text-[var(--foreground)]">{confirmDelete.filename}</span>
</p>
<p className="text-sm text-[var(--muted-foreground)] mb-6">
{t("import.history.deleteConfirm", { count: confirmDelete.rowCount })}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setConfirmDelete(null)}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={() => {
handleDelete(confirmDelete.id);
setConfirmDelete(null);
}}
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
>
{t("common.delete")}
</button>
</div>
</div>
</div>
)}
{/* Confirm delete all imports */}
{confirmDeleteAll && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-full bg-[var(--negative)]/10">
<AlertTriangle size={20} className="text-[var(--negative)]" />
</div>
<h2 className="text-lg font-semibold">{t("import.history.deleteAll")}</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-6">
{t("import.history.deleteAllConfirm")}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setConfirmDeleteAll(false)}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={() => {
handleDeleteAll();
setConfirmDeleteAll(false);
}}
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
>
{t("common.delete")}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { Wand2, Check } from "lucide-react";
import type {
ScannedSource,
ScannedFile,
@ -13,6 +13,7 @@ interface SourceConfigPanelProps {
source: ScannedSource;
config: SourceConfig;
selectedFiles: ScannedFile[];
importedFileNames?: Set<string>;
headers: string[];
onConfigChange: (config: SourceConfig) => void;
onFileToggle: (file: ScannedFile) => void;
@ -25,6 +26,7 @@ export default function SourceConfigPanel({
source,
config,
selectedFiles,
importedFileNames,
headers,
onConfigChange,
onFileToggle,
@ -222,10 +224,13 @@ export default function SourceConfigPanel({
const isSelected = selectedFiles.some(
(f) => f.file_path === file.file_path
);
const isImported = importedFileNames?.has(file.filename) ?? false;
return (
<label
key={file.file_path}
className="flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--muted)] cursor-pointer text-sm"
className={`flex items-center gap-2 px-3 py-2 rounded-lg hover:bg-[var(--muted)] cursor-pointer text-sm ${
isImported ? "opacity-60" : ""
}`}
>
<input
type="checkbox"
@ -234,6 +239,12 @@ export default function SourceConfigPanel({
className="accent-[var(--primary)]"
/>
<span className="flex-1">{file.filename}</span>
{isImported && (
<span className="flex items-center gap-1 text-xs text-[var(--positive)]">
<Check size={12} />
{t("import.config.alreadyImported")}
</span>
)}
<span className="text-xs text-[var(--muted-foreground)]">
{(file.size_bytes / 1024).toFixed(1)} KB
</span>

View file

@ -1,11 +1,10 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import type { BudgetRow, BudgetTemplate } from "../shared/types";
import type { BudgetYearRow, BudgetTemplate } from "../shared/types";
import {
getActiveCategories,
getBudgetEntriesForMonth,
getActualsByCategory,
getBudgetEntriesForYear,
upsertBudgetEntry,
deleteBudgetEntry,
upsertBudgetEntriesForYear,
getAllTemplates,
saveAsTemplate as saveAsTemplateSvc,
applyTemplate as applyTemplateSvc,
@ -14,8 +13,7 @@ import {
interface BudgetState {
year: number;
month: number;
rows: BudgetRow[];
rows: BudgetYearRow[];
templates: BudgetTemplate[];
isLoading: boolean;
isSaving: boolean;
@ -26,14 +24,12 @@ 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 } };
| { type: "SET_DATA"; payload: { rows: BudgetYearRow[]; templates: BudgetTemplate[] } }
| { type: "SET_YEAR"; payload: number };
function initialState(): BudgetState {
const now = new Date();
return {
year: now.getFullYear(),
month: now.getMonth() + 1,
year: new Date().getFullYear(),
rows: [],
templates: [],
isLoading: false,
@ -57,8 +53,8 @@ function reducer(state: BudgetState, action: BudgetAction): BudgetState {
templates: action.payload.templates,
isLoading: false,
};
case "NAVIGATE_MONTH":
return { ...state, year: action.payload.year, month: action.payload.month };
case "SET_YEAR":
return { ...state, year: action.payload };
default:
return state;
}
@ -70,45 +66,43 @@ export function useBudget() {
const [state, dispatch] = useReducer(reducer, undefined, initialState);
const fetchIdRef = useRef(0);
const refreshData = useCallback(async (year: number, month: number) => {
const refreshData = useCallback(async (year: 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([
const [categories, entries, templates] = await Promise.all([
getActiveCategories(),
getBudgetEntriesForMonth(year, month),
getActualsByCategory(year, month),
getBudgetEntriesForYear(year),
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]));
// Build a map: categoryId -> month(1-12) -> amount
const entryMap = new Map<number, Map<number, number>>();
for (const e of entries) {
if (!entryMap.has(e.category_id)) entryMap.set(e.category_id, new Map());
entryMap.get(e.category_id)!.set(e.month, e.amount);
}
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);
const rows: BudgetYearRow[] = categories.map((cat) => {
const monthMap = entryMap.get(cat.id);
const months: number[] = [];
let annual = 0;
for (let m = 1; m <= 12; m++) {
const val = monthMap?.get(m) ?? 0;
months.push(val);
annual += val;
}
return {
category_id: cat.id,
category_name: cat.name,
category_color: cat.color || "#9ca3af",
category_type: cat.type,
planned,
actual,
difference,
notes: entry?.notes,
months,
annual,
};
});
@ -130,28 +124,19 @@ export function useBudget() {
}, []);
useEffect(() => {
refreshData(state.year, state.month);
}, [state.year, state.month, refreshData]);
refreshData(state.year);
}, [state.year, 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 navigateYear = useCallback((delta: -1 | 1) => {
dispatch({ type: "SET_YEAR", payload: state.year + delta });
}, [state.year]);
const updatePlanned = useCallback(
async (categoryId: number, amount: number, notes?: string) => {
async (categoryId: number, month: number, amount: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await upsertBudgetEntry(categoryId, state.year, state.month, amount, notes);
await refreshData(state.year, state.month);
await upsertBudgetEntry(categoryId, state.year, month, amount);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -161,15 +146,21 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
[state.year, refreshData]
);
const removePlanned = useCallback(
async (categoryId: number) => {
const splitEvenly = useCallback(
async (categoryId: number, annualAmount: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteBudgetEntry(categoryId, state.year, state.month);
await refreshData(state.year, state.month);
const base = Math.floor((annualAmount / 12) * 100) / 100;
const remainder = Math.round((annualAmount - base * 12) * 100);
const amounts: number[] = [];
for (let m = 0; m < 12; m++) {
amounts.push(m < remainder ? base + 0.01 : base);
}
await upsertBudgetEntriesForYear(categoryId, state.year, amounts);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -179,18 +170,19 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
[state.year, refreshData]
);
const saveTemplate = useCallback(
async (name: string, description?: string) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
// Save template from January values (template is a single-month snapshot)
const entries = state.rows
.filter((r) => r.planned !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.planned }));
.filter((r) => r.months[0] !== 0)
.map((r) => ({ category_id: r.category_id, amount: r.months[0] }));
await saveAsTemplateSvc(name, description, entries);
await refreshData(state.year, state.month);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -200,15 +192,15 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.rows, state.year, state.month, refreshData]
[state.rows, state.year, refreshData]
);
const applyTemplate = useCallback(
async (templateId: number) => {
async (templateId: number, month: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
await applyTemplateSvc(templateId, state.year, state.month);
await refreshData(state.year, state.month);
await applyTemplateSvc(templateId, state.year, month);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -218,7 +210,27 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
[state.year, refreshData]
);
const applyTemplateAllMonths = useCallback(
async (templateId: number) => {
dispatch({ type: "SET_SAVING", payload: true });
try {
for (let m = 1; m <= 12; m++) {
await applyTemplateSvc(templateId, state.year, m);
}
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
} finally {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, refreshData]
);
const deleteTemplate = useCallback(
@ -226,7 +238,7 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: true });
try {
await deleteTemplateSvc(templateId);
await refreshData(state.year, state.month);
await refreshData(state.year);
} catch (e) {
dispatch({
type: "SET_ERROR",
@ -236,16 +248,17 @@ export function useBudget() {
dispatch({ type: "SET_SAVING", payload: false });
}
},
[state.year, state.month, refreshData]
[state.year, refreshData]
);
return {
state,
navigateMonth,
navigateYear,
updatePlanned,
removePlanned,
splitEvenly,
saveTemplate,
applyTemplate,
applyTemplateAllMonths,
deleteTemplate,
};
}

View file

@ -1,5 +1,4 @@
import { useReducer, useCallback, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import type { ImportedFileWithSource } from "../shared/types";
import {
getAllImportedFiles,
@ -48,7 +47,6 @@ function reducer(
export function useImportHistory(onChanged?: () => void) {
const [state, dispatch] = useReducer(reducer, initialState);
const fetchIdRef = useRef(0);
const { t } = useTranslation();
const loadHistory = useCallback(async () => {
const fetchId = ++fetchIdRef.current;
@ -69,11 +67,7 @@ export function useImportHistory(onChanged?: () => void) {
}, []);
const handleDelete = useCallback(
async (fileId: number, rowCount: number) => {
const ok = confirm(
t("import.history.deleteConfirm", { count: rowCount })
);
if (!ok) return;
async (fileId: number) => {
dispatch({ type: "SET_DELETING", payload: true });
try {
await deleteImportWithTransactions(fileId);
@ -85,12 +79,10 @@ export function useImportHistory(onChanged?: () => void) {
dispatch({ type: "SET_DELETING", payload: false });
}
},
[loadHistory, onChanged, t]
[loadHistory, onChanged]
);
const handleDeleteAll = useCallback(async () => {
const ok = confirm(t("import.history.deleteAllConfirm"));
if (!ok) return;
dispatch({ type: "SET_DELETING", payload: true });
try {
await deleteAllImportsWithTransactions();
@ -101,7 +93,7 @@ export function useImportHistory(onChanged?: () => void) {
} finally {
dispatch({ type: "SET_DELETING", payload: false });
}
}, [loadHistory, onChanged, t]);
}, [loadHistory, onChanged]);
useEffect(() => {
loadHistory();

View file

@ -261,8 +261,21 @@ export function useImportWizard() {
const selectSource = useCallback(
async (source: ScannedSource) => {
dispatch({ type: "SET_SELECTED_SOURCE", payload: source });
dispatch({ type: "SET_SELECTED_FILES", payload: source.files });
// Sort files: new files first, then already-imported
const importedNames = state.importedFilesBySource.get(source.folder_name);
const sorted = [...source.files].sort((a, b) => {
const aImported = importedNames?.has(a.filename) ?? false;
const bImported = importedNames?.has(b.filename) ?? false;
if (aImported !== bImported) return aImported ? 1 : -1;
return a.filename.localeCompare(b.filename);
});
const sortedSource = { ...source, files: sorted };
// Pre-select only new files
const newFiles = sorted.filter((f) => !importedNames?.has(f.filename));
dispatch({ type: "SET_SELECTED_SOURCE", payload: sortedSource });
dispatch({ type: "SET_SELECTED_FILES", payload: newFiles });
// Check if this source already has config in DB
const existing = await getSourceByName(source.folder_name);

View file

@ -83,6 +83,7 @@
"creditColumn": "Credit column",
"selectFiles": "Files to import",
"selectAll": "Select all",
"alreadyImported": "Imported",
"autoDetect": "Auto-detect"
},
"preview": {
@ -284,6 +285,10 @@
"planned": "Planned",
"actual": "Actual",
"difference": "Difference",
"annual": "Annual",
"splitEvenly": "Split evenly across 12 months",
"applyToMonth": "Apply to month",
"allMonths": "All 12 months",
"expenses": "Expenses",
"income": "Income",
"transfers": "Transfers",
@ -300,11 +305,11 @@
"help": {
"title": "How to use Budget",
"tips": [
"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"
"Use the year navigator to switch between years",
"Click on any month cell to edit the planned amount — press Enter to save, Escape to cancel, Tab to move to next month",
"The Annual column shows the total of all 12 months",
"Use the split button to distribute the annual total evenly across all months",
"Save your budget as a template and apply it to specific months or all 12 at once"
]
}
},
@ -361,6 +366,20 @@
"transactions": "transactions",
"clickToShow": "Click to show"
},
"months": {
"jan": "Jan",
"feb": "Feb",
"mar": "Mar",
"apr": "Apr",
"may": "May",
"jun": "Jun",
"jul": "Jul",
"aug": "Aug",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Dec"
},
"common": {
"save": "Save",
"cancel": "Cancel",

View file

@ -83,6 +83,7 @@
"creditColumn": "Colonne crédit",
"selectFiles": "Fichiers à importer",
"selectAll": "Tout sélectionner",
"alreadyImported": "Importé",
"autoDetect": "Auto-détecter"
},
"preview": {
@ -284,6 +285,10 @@
"planned": "Prévu",
"actual": "Réel",
"difference": "Écart",
"annual": "Annuel",
"splitEvenly": "Répartir également sur 12 mois",
"applyToMonth": "Appliquer au mois",
"allMonths": "Les 12 mois",
"expenses": "Dépenses",
"income": "Revenus",
"transfers": "Transferts",
@ -300,11 +305,11 @@
"help": {
"title": "Comment utiliser le Budget",
"tips": [
"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"
"Utilisez le navigateur d'année pour changer d'année",
"Cliquez sur une cellule de mois pour modifier le montant prévu — Entrée pour sauvegarder, Échap pour annuler, Tab pour passer au mois suivant",
"La colonne Annuel affiche le total des 12 mois",
"Utilisez le bouton de répartition pour distribuer le total annuel également sur tous les mois",
"Sauvegardez votre budget comme modèle et appliquez-le à des mois spécifiques ou aux 12 mois d'un coup"
]
}
},
@ -361,6 +366,20 @@
"transactions": "transactions",
"clickToShow": "Cliquer pour afficher"
},
"months": {
"jan": "Jan",
"feb": "Fév",
"mar": "Mar",
"apr": "Avr",
"may": "Mai",
"jun": "Jun",
"jul": "Jul",
"aug": "Aoû",
"sep": "Sep",
"oct": "Oct",
"nov": "Nov",
"dec": "Déc"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",

View file

@ -1,8 +1,7 @@
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 YearNavigator from "../components/budget/YearNavigator";
import BudgetTable from "../components/budget/BudgetTable";
import TemplateActions from "../components/budget/TemplateActions";
@ -10,14 +9,16 @@ export default function BudgetPage() {
const { t } = useTranslation();
const {
state,
navigateMonth,
navigateYear,
updatePlanned,
splitEvenly,
saveTemplate,
applyTemplate,
applyTemplateAllMonths,
deleteTemplate,
} = useBudget();
const { year, month, rows, templates, isLoading, isSaving, error } = state;
const { year, rows, templates, isLoading, isSaving, error } = state;
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
@ -30,11 +31,12 @@ export default function BudgetPage() {
<TemplateActions
templates={templates}
onApply={applyTemplate}
onApplyAllMonths={applyTemplateAllMonths}
onSave={saveTemplate}
onDelete={deleteTemplate}
disabled={isSaving}
/>
<MonthNavigator year={year} month={month} onNavigate={navigateMonth} />
<YearNavigator year={year} onNavigate={navigateYear} />
</div>
</div>
@ -44,8 +46,11 @@ export default function BudgetPage() {
</div>
)}
<BudgetSummaryCards rows={rows} />
<BudgetTable rows={rows} onUpdatePlanned={updatePlanned} />
<BudgetTable
rows={rows}
onUpdatePlanned={updatePlanned}
onSplitEvenly={splitEvenly}
/>
</div>
);
}

View file

@ -1,6 +1,6 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Plus, RotateCcw, List } from "lucide-react";
import { Plus, RotateCcw, List, AlertTriangle } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
import { useCategories } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree";
@ -10,6 +10,7 @@ import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
export default function CategoriesPage() {
const { t } = useTranslation();
const [showAllKeywords, setShowAllKeywords] = useState(false);
const [showReinitConfirm, setShowReinitConfirm] = useState(false);
const {
state,
selectCategory,
@ -25,9 +26,8 @@ export default function CategoriesPage() {
} = useCategories();
const handleReinitialize = async () => {
if (confirm(t("categories.reinitializeConfirm"))) {
await reinitializeCategories();
}
setShowReinitConfirm(false);
await reinitializeCategories();
};
const selectedCategory =
@ -55,7 +55,7 @@ export default function CategoriesPage() {
{t("categories.allKeywords")}
</button>
<button
onClick={handleReinitialize}
onClick={() => setShowReinitConfirm(true)}
disabled={state.isSaving}
className="flex items-center gap-2 px-3 py-2 rounded-lg border border-[var(--border)] text-sm font-medium hover:bg-[var(--muted)] transition-colors disabled:opacity-50"
>
@ -113,6 +113,37 @@ export default function CategoriesPage() {
/>
</div>
)}
{/* Reinitialize confirmation modal */}
{showReinitConfirm && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-lg p-6 max-w-md w-full mx-4">
<div className="flex items-center gap-3 mb-4">
<div className="p-2 rounded-full bg-[var(--negative)]/10">
<AlertTriangle size={20} className="text-[var(--negative)]" />
</div>
<h2 className="text-lg font-semibold">{t("categories.reinitialize")}</h2>
</div>
<p className="text-sm text-[var(--muted-foreground)] mb-6">
{t("categories.reinitializeConfirm")}
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setShowReinitConfirm(false)}
className="px-4 py-2 text-sm rounded-lg border border-[var(--border)] hover:bg-[var(--muted)] transition-colors"
>
{t("common.cancel")}
</button>
<button
onClick={handleReinitialize}
className="px-4 py-2 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 transition-opacity"
>
{t("common.confirm")}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View file

@ -87,6 +87,7 @@ export default function ImportPage() {
source={state.selectedSource}
config={state.sourceConfig}
selectedFiles={state.selectedFiles}
importedFileNames={state.importedFilesBySource.get(state.selectedSource.folder_name)}
headers={state.previewHeaders}
onConfigChange={updateConfig}
onFileToggle={toggleFile}

View file

@ -74,6 +74,41 @@ export async function getActualsByCategory(
);
}
export async function getBudgetEntriesForYear(
year: number
): Promise<BudgetEntry[]> {
const db = await getDb();
return db.select<BudgetEntry[]>(
"SELECT * FROM budget_entries WHERE year = $1",
[year]
);
}
export async function upsertBudgetEntriesForYear(
categoryId: number,
year: number,
amounts: number[]
): Promise<void> {
const db = await getDb();
for (let m = 0; m < 12; m++) {
const month = m + 1;
const amount = amounts[m] ?? 0;
if (amount === 0) {
await db.execute(
"DELETE FROM budget_entries WHERE category_id = $1 AND year = $2 AND month = $3",
[categoryId, year, month]
);
} else {
await db.execute(
`INSERT INTO budget_entries (category_id, year, month, amount)
VALUES ($1, $2, $3, $4)
ON CONFLICT(category_id, year, month) DO UPDATE SET amount = $4, updated_at = CURRENT_TIMESTAMP`,
[categoryId, year, month, amount]
);
}
}
}
// Templates
export async function getAllTemplates(): Promise<BudgetTemplate[]> {

View file

@ -131,6 +131,15 @@ export interface BudgetRow {
notes?: string;
}
export interface BudgetYearRow {
category_id: number;
category_name: string;
category_color: string;
category_type: "expense" | "income" | "transfer";
months: number[]; // index 0-11 = Jan-Dec planned amounts
annual: number; // computed sum
}
export interface UserPreference {
key: string;
value: string;