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", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.2.3", "version": "0.2.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

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

View file

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

View file

@ -1,50 +1,79 @@
import { useState, useRef, useEffect, Fragment } from "react"; import { useState, useRef, useEffect, Fragment } from "react";
import { useTranslation } from "react-i18next"; 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 { interface BudgetTableProps {
rows: BudgetRow[]; rows: BudgetYearRow[];
onUpdatePlanned: (categoryId: number, amount: number) => void; 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 { t } = useTranslation();
const [editingCategoryId, setEditingCategoryId] = useState<number | null>(null); const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
const [editingValue, setEditingValue] = useState(""); const [editingValue, setEditingValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (editingCategoryId !== null && inputRef.current) { if (editingCell && inputRef.current) {
inputRef.current.focus(); inputRef.current.focus();
inputRef.current.select(); inputRef.current.select();
} }
}, [editingCategoryId]); }, [editingCell]);
const handleStartEdit = (row: BudgetRow) => { const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => {
setEditingCategoryId(row.category_id); setEditingCell({ categoryId, monthIdx });
setEditingValue(row.planned === 0 ? "" : String(row.planned)); setEditingValue(currentValue === 0 ? "" : String(currentValue));
}; };
const handleSave = () => { const handleSave = () => {
if (editingCategoryId === null) return; if (!editingCell) return;
const amount = parseFloat(editingValue) || 0; const amount = parseFloat(editingValue) || 0;
onUpdatePlanned(editingCategoryId, amount); onUpdatePlanned(editingCell.categoryId, editingCell.monthIdx + 1, amount);
setEditingCategoryId(null); setEditingCell(null);
}; };
const handleCancel = () => { const handleCancel = () => {
setEditingCategoryId(null); setEditingCell(null);
}; };
const handleKeyDown = (e: React.KeyboardEvent) => { const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") handleSave(); if (e.key === "Enter") handleSave();
if (e.key === "Escape") handleCancel(); 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 // Group rows by type
const grouped: Record<string, BudgetRow[]> = {}; const grouped: Record<string, BudgetYearRow[]> = {};
for (const row of rows) { for (const row of rows) {
const key = row.category_type; const key = row.category_type;
if (!grouped[key]) grouped[key] = []; if (!grouped[key]) grouped[key] = [];
@ -58,9 +87,17 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
transfer: "budget.transfers", transfer: "budget.transfers",
}; };
const totalPlanned = rows.reduce((s, r) => s + r.planned, 0); // Column totals
const totalActual = rows.reduce((s, r) => s + Math.abs(r.actual), 0); const monthTotals: number[] = Array(12).fill(0);
const totalDifference = totalPlanned - totalActual; 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) { if (rows.length === 0) {
return ( return (
@ -71,22 +108,21 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
} }
return ( return (
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-hidden"> <div className="bg-[var(--card)] rounded-xl border border-[var(--border)] overflow-x-auto">
<table className="w-full text-sm"> <table className="w-full text-sm whitespace-nowrap">
<thead> <thead>
<tr className="border-b border-[var(--border)]"> <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")} {t("budget.category")}
</th> </th>
<th className="text-right py-3 px-4 font-medium text-[var(--muted-foreground)] w-36"> <th className="text-right py-2.5 px-2 font-medium text-[var(--muted-foreground)] min-w-[90px]">
{t("budget.planned")} {t("budget.annual")}
</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> </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> </tr>
</thead> </thead>
<tbody> <tbody>
@ -97,8 +133,8 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
<Fragment key={type}> <Fragment key={type}>
<tr> <tr>
<td <td
colSpan={4} colSpan={totalCols}
className="py-2 px-4 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]" className="py-1.5 px-3 text-xs font-semibold uppercase tracking-wider text-[var(--muted-foreground)] bg-[var(--muted)]"
> >
{t(typeLabelKeys[type])} {t(typeLabelKeys[type])}
</td> </td>
@ -106,82 +142,81 @@ export default function BudgetTable({ rows, onUpdatePlanned }: BudgetTableProps)
{group.map((row) => ( {group.map((row) => (
<tr <tr
key={row.category_id} 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"> <div className="flex items-center gap-2">
<span <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 }} style={{ backgroundColor: row.category_color }}
/> />
<span>{row.category_name}</span> <span className="truncate text-xs">{row.category_name}</span>
</div> </div>
</td> </td>
<td className="py-2.5 px-4 text-right"> {/* Annual total + split button */}
{editingCategoryId === row.category_id ? ( <td className="py-2 px-2 text-right">
<input <div className="flex items-center justify-end gap-1">
ref={inputRef} <span className="font-medium text-xs">
type="number" {row.annual === 0 ? (
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> <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> </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> </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> </tr>
))} ))}
</Fragment> </Fragment>
); );
})} })}
{/* Totals row */}
<tr className="bg-[var(--muted)] font-semibold"> <tr className="bg-[var(--muted)] font-semibold">
<td className="py-3 px-4">{t("common.total")}</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-3 px-4 text-right">{fmt.format(totalPlanned)}</td> <td className="py-2.5 px-2 text-right text-xs">{fmt.format(annualTotal)}</td>
<td className="py-3 px-4 text-right">{fmt.format(totalActual)}</td> {monthTotals.map((total, mIdx) => (
<td className="py-3 px-4 text-right"> <td key={mIdx} className="py-2.5 px-2 text-right text-xs">
<span {fmt.format(total)}
className={ </td>
totalDifference >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" ))}
}
>
{fmt.format(totalDifference)}
</span>
</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View file

@ -5,15 +5,23 @@ import type { BudgetTemplate } from "../../shared/types";
interface TemplateActionsProps { interface TemplateActionsProps {
templates: BudgetTemplate[]; templates: BudgetTemplate[];
onApply: (templateId: number) => void; onApply: (templateId: number, month: number) => void;
onApplyAllMonths: (templateId: number) => void;
onSave: (name: string, description?: string) => void; onSave: (name: string, description?: string) => void;
onDelete: (templateId: number) => void; onDelete: (templateId: number) => void;
disabled?: boolean; 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({ export default function TemplateActions({
templates, templates,
onApply, onApply,
onApplyAllMonths,
onSave, onSave,
onDelete, onDelete,
disabled, disabled,
@ -22,6 +30,7 @@ export default function TemplateActions({
const [showApply, setShowApply] = useState(false); const [showApply, setShowApply] = useState(false);
const [showSave, setShowSave] = useState(false); const [showSave, setShowSave] = useState(false);
const [templateName, setTemplateName] = useState(""); const [templateName, setTemplateName] = useState("");
const [selectedTemplate, setSelectedTemplate] = useState<number | null>(null);
const applyRef = useRef<HTMLDivElement>(null); const applyRef = useRef<HTMLDivElement>(null);
const saveRef = useRef<HTMLDivElement>(null); const saveRef = useRef<HTMLDivElement>(null);
@ -30,6 +39,7 @@ export default function TemplateActions({
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
if (showApply && applyRef.current && !applyRef.current.contains(e.target as Node)) { if (showApply && applyRef.current && !applyRef.current.contains(e.target as Node)) {
setShowApply(false); setShowApply(false);
setSelectedTemplate(null);
} }
if (showSave && saveRef.current && !saveRef.current.contains(e.target as Node)) { if (showSave && saveRef.current && !saveRef.current.contains(e.target as Node)) {
setShowSave(false); 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Apply template */} {/* Apply template */}
<div ref={applyRef} className="relative"> <div ref={applyRef} className="relative">
<button <button
onClick={() => { setShowApply(!showApply); setShowSave(false); }} onClick={() => { setShowApply(!showApply); setShowSave(false); setSelectedTemplate(null); }}
disabled={disabled} 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" 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")} {t("budget.applyTemplate")}
</button> </button>
{showApply && ( {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"> <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">
{templates.length === 0 ? ( {selectedTemplate === null ? (
<p className="px-4 py-3 text-sm text-[var(--muted-foreground)]"> // Step 1: Pick a template
{t("budget.noTemplates")} templates.length === 0 ? (
</p> <p className="px-4 py-3 text-sm text-[var(--muted-foreground)]">
) : ( {t("budget.noTemplates")}
templates.map((tmpl) => ( </p>
<div ) : (
key={tmpl.id} templates.map((tmpl) => (
className="flex items-center justify-between px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors" <div
onClick={() => { onApply(tmpl.id); setShowApply(false); }} key={tmpl.id}
> className="flex items-center justify-between px-4 py-2 hover:bg-[var(--muted)] cursor-pointer transition-colors"
<span className="text-sm truncate">{tmpl.name}</span> onClick={() => handleSelectTemplate(tmpl.id)}
<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} /> <span className="text-sm truncate">{tmpl.name}</span>
</button> <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> </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> </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 { useTranslation } from "react-i18next";
import { Trash2, Inbox } from "lucide-react"; import { Trash2, Inbox, AlertTriangle } from "lucide-react";
import { useImportHistory } from "../../hooks/useImportHistory"; import { useImportHistory } from "../../hooks/useImportHistory";
interface ImportHistoryPanelProps { interface ImportHistoryPanelProps {
@ -11,6 +12,8 @@ export default function ImportHistoryPanel({
}: ImportHistoryPanelProps) { }: ImportHistoryPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const { state, handleDelete, handleDeleteAll } = useImportHistory(onChanged); const { state, handleDelete, handleDeleteAll } = useImportHistory(onChanged);
const [confirmDelete, setConfirmDelete] = useState<{ id: number; filename: string; rowCount: number } | null>(null);
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
return ( return (
<div className="mt-8"> <div className="mt-8">
@ -20,7 +23,7 @@ export default function ImportHistoryPanel({
</h2> </h2>
{state.files.length > 0 && ( {state.files.length > 0 && (
<button <button
onClick={handleDeleteAll} onClick={() => setConfirmDeleteAll(true)}
disabled={state.isDeleting} disabled={state.isDeleting}
className="px-3 py-1.5 text-sm rounded-lg bg-[var(--negative)] text-white hover:opacity-90 disabled:opacity-50" 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>
<td className="px-4 py-2"> <td className="px-4 py-2">
<button <button
onClick={() => handleDelete(file.id, file.row_count)} onClick={() => setConfirmDelete({ id: file.id, filename: file.filename, rowCount: file.row_count })}
disabled={state.isDeleting} disabled={state.isDeleting}
className="p-1 rounded hover:bg-[var(--muted)] text-[var(--negative)] disabled:opacity-50" className="p-1 rounded hover:bg-[var(--muted)] text-[var(--negative)] disabled:opacity-50"
title={t("common.delete")} title={t("common.delete")}
@ -113,6 +116,77 @@ export default function ImportHistoryPanel({
</table> </table>
</div> </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> </div>
); );
} }

View file

@ -1,5 +1,5 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react"; import { Wand2, Check } from "lucide-react";
import type { import type {
ScannedSource, ScannedSource,
ScannedFile, ScannedFile,
@ -13,6 +13,7 @@ interface SourceConfigPanelProps {
source: ScannedSource; source: ScannedSource;
config: SourceConfig; config: SourceConfig;
selectedFiles: ScannedFile[]; selectedFiles: ScannedFile[];
importedFileNames?: Set<string>;
headers: string[]; headers: string[];
onConfigChange: (config: SourceConfig) => void; onConfigChange: (config: SourceConfig) => void;
onFileToggle: (file: ScannedFile) => void; onFileToggle: (file: ScannedFile) => void;
@ -25,6 +26,7 @@ export default function SourceConfigPanel({
source, source,
config, config,
selectedFiles, selectedFiles,
importedFileNames,
headers, headers,
onConfigChange, onConfigChange,
onFileToggle, onFileToggle,
@ -222,10 +224,13 @@ export default function SourceConfigPanel({
const isSelected = selectedFiles.some( const isSelected = selectedFiles.some(
(f) => f.file_path === file.file_path (f) => f.file_path === file.file_path
); );
const isImported = importedFileNames?.has(file.filename) ?? false;
return ( return (
<label <label
key={file.file_path} 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 <input
type="checkbox" type="checkbox"
@ -234,6 +239,12 @@ export default function SourceConfigPanel({
className="accent-[var(--primary)]" className="accent-[var(--primary)]"
/> />
<span className="flex-1">{file.filename}</span> <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)]"> <span className="text-xs text-[var(--muted-foreground)]">
{(file.size_bytes / 1024).toFixed(1)} KB {(file.size_bytes / 1024).toFixed(1)} KB
</span> </span>

View file

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

View file

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

View file

@ -261,8 +261,21 @@ export function useImportWizard() {
const selectSource = useCallback( const selectSource = useCallback(
async (source: ScannedSource) => { async (source: ScannedSource) => {
dispatch({ type: "SET_SELECTED_SOURCE", payload: source }); // Sort files: new files first, then already-imported
dispatch({ type: "SET_SELECTED_FILES", payload: source.files }); 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 // Check if this source already has config in DB
const existing = await getSourceByName(source.folder_name); const existing = await getSourceByName(source.folder_name);

View file

@ -83,6 +83,7 @@
"creditColumn": "Credit column", "creditColumn": "Credit column",
"selectFiles": "Files to import", "selectFiles": "Files to import",
"selectAll": "Select all", "selectAll": "Select all",
"alreadyImported": "Imported",
"autoDetect": "Auto-detect" "autoDetect": "Auto-detect"
}, },
"preview": { "preview": {
@ -284,6 +285,10 @@
"planned": "Planned", "planned": "Planned",
"actual": "Actual", "actual": "Actual",
"difference": "Difference", "difference": "Difference",
"annual": "Annual",
"splitEvenly": "Split evenly across 12 months",
"applyToMonth": "Apply to month",
"allMonths": "All 12 months",
"expenses": "Expenses", "expenses": "Expenses",
"income": "Income", "income": "Income",
"transfers": "Transfers", "transfers": "Transfers",
@ -300,11 +305,11 @@
"help": { "help": {
"title": "How to use Budget", "title": "How to use Budget",
"tips": [ "tips": [
"Use the month navigator to switch between months", "Use the year navigator to switch between years",
"Click on a planned amount to edit it inline — press Enter to save or Escape to cancel", "Click on any month cell to edit the planned amount — press Enter to save, Escape to cancel, Tab to move to next month",
"The actual column shows real spending from your imported transactions", "The Annual column shows the total of all 12 months",
"Green means under budget, red means over budget", "Use the split button to distribute the annual total evenly across all months",
"Save your budget as a template and apply it to other months quickly" "Save your budget as a template and apply it to specific months or all 12 at once"
] ]
} }
}, },
@ -361,6 +366,20 @@
"transactions": "transactions", "transactions": "transactions",
"clickToShow": "Click to show" "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": { "common": {
"save": "Save", "save": "Save",
"cancel": "Cancel", "cancel": "Cancel",

View file

@ -83,6 +83,7 @@
"creditColumn": "Colonne crédit", "creditColumn": "Colonne crédit",
"selectFiles": "Fichiers à importer", "selectFiles": "Fichiers à importer",
"selectAll": "Tout sélectionner", "selectAll": "Tout sélectionner",
"alreadyImported": "Importé",
"autoDetect": "Auto-détecter" "autoDetect": "Auto-détecter"
}, },
"preview": { "preview": {
@ -284,6 +285,10 @@
"planned": "Prévu", "planned": "Prévu",
"actual": "Réel", "actual": "Réel",
"difference": "Écart", "difference": "Écart",
"annual": "Annuel",
"splitEvenly": "Répartir également sur 12 mois",
"applyToMonth": "Appliquer au mois",
"allMonths": "Les 12 mois",
"expenses": "Dépenses", "expenses": "Dépenses",
"income": "Revenus", "income": "Revenus",
"transfers": "Transferts", "transfers": "Transferts",
@ -300,11 +305,11 @@
"help": { "help": {
"title": "Comment utiliser le Budget", "title": "Comment utiliser le Budget",
"tips": [ "tips": [
"Utilisez le navigateur de mois pour passer d'un mois à l'autre", "Utilisez le navigateur d'année pour changer d'année",
"Cliquez sur un montant prévu pour le modifier — appuyez sur Entrée pour sauvegarder ou Échap pour annuler", "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 réel affiche les dépenses réelles de vos transactions importées", "La colonne Annuel affiche le total des 12 mois",
"Vert signifie sous le budget, rouge signifie au-dessus du budget", "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 rapidement à d'autres 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", "transactions": "transactions",
"clickToShow": "Cliquer pour afficher" "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": { "common": {
"save": "Enregistrer", "save": "Enregistrer",
"cancel": "Annuler", "cancel": "Annuler",

View file

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

View file

@ -1,6 +1,6 @@
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; 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 { PageHelp } from "../components/shared/PageHelp";
import { useCategories } from "../hooks/useCategories"; import { useCategories } from "../hooks/useCategories";
import CategoryTree from "../components/categories/CategoryTree"; import CategoryTree from "../components/categories/CategoryTree";
@ -10,6 +10,7 @@ import AllKeywordsPanel from "../components/categories/AllKeywordsPanel";
export default function CategoriesPage() { export default function CategoriesPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [showAllKeywords, setShowAllKeywords] = useState(false); const [showAllKeywords, setShowAllKeywords] = useState(false);
const [showReinitConfirm, setShowReinitConfirm] = useState(false);
const { const {
state, state,
selectCategory, selectCategory,
@ -25,9 +26,8 @@ export default function CategoriesPage() {
} = useCategories(); } = useCategories();
const handleReinitialize = async () => { const handleReinitialize = async () => {
if (confirm(t("categories.reinitializeConfirm"))) { setShowReinitConfirm(false);
await reinitializeCategories(); await reinitializeCategories();
}
}; };
const selectedCategory = const selectedCategory =
@ -55,7 +55,7 @@ export default function CategoriesPage() {
{t("categories.allKeywords")} {t("categories.allKeywords")}
</button> </button>
<button <button
onClick={handleReinitialize} onClick={() => setShowReinitConfirm(true)}
disabled={state.isSaving} 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" 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> </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> </div>
); );
} }

View file

@ -87,6 +87,7 @@ export default function ImportPage() {
source={state.selectedSource} source={state.selectedSource}
config={state.sourceConfig} config={state.sourceConfig}
selectedFiles={state.selectedFiles} selectedFiles={state.selectedFiles}
importedFileNames={state.importedFilesBySource.get(state.selectedSource.folder_name)}
headers={state.previewHeaders} headers={state.previewHeaders}
onConfigChange={updateConfig} onConfigChange={updateConfig}
onFileToggle={toggleFile} 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 // Templates
export async function getAllTemplates(): Promise<BudgetTemplate[]> { export async function getAllTemplates(): Promise<BudgetTemplate[]> {

View file

@ -131,6 +131,15 @@ export interface BudgetRow {
notes?: string; 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 { export interface UserPreference {
key: string; key: string;
value: string; value: string;