feat: 12-month budget grid, import UX improvements, confirmation dialogs (v0.2.4)
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
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:
parent
29a1a15120
commit
720f52bad6
18 changed files with 561 additions and 223 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"private": true,
|
||||
"version": "0.2.3",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
28
src/components/budget/YearNavigator.tsx
Normal file
28
src/components/budget/YearNavigator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue