feat: add chart patterns, context menu, and import preview popup (v0.2.3)
Some checks failed
Release / build (windows-latest) (push) Has been cancelled

- Add SVG fill patterns to differentiate chart categories beyond color
- Add right-click context menu on charts to hide categories or view transactions
- Add transaction detail modal showing all transactions for a category
- Change import preview from wizard step to popup modal
- Add direct "Check Duplicates" button skipping preview step
- Bump version to 0.2.3

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-13 23:55:19 +00:00
parent def17d13fb
commit 29a1a15120
23 changed files with 1284 additions and 372 deletions

27
CHANGELOG.md Normal file
View file

@ -0,0 +1,27 @@
# Changelog
## 0.2.3
### New Features
- **Chart patterns**: Added SVG fill patterns (diagonal lines, dots, crosshatch, etc.) to differentiate categories in bar charts, pie chart, and stacked bar charts beyond just color
- **Chart context menu**: Right-click any category in a chart to hide it or view its transactions in a detail popup
- **Hidden categories**: Hidden categories appear as dismissible chips above charts with a "Show all" button to restore them
- **Transaction detail modal**: View all transactions composing a category's total directly from any chart
- **Import preview popup**: The data preview is now a popup modal instead of a separate wizard step, allowing quick inspection without leaving the configuration page
- **Direct duplicate check**: New "Check Duplicates" button on the import configuration page skips directly to duplicate validation without requiring a preview first
### Improvements
- Import wizard flow simplified: source-config → duplicate-check (preview is optional via popup)
- Duplicate-check back button now returns to source configuration instead of the removed preview step
- Added `categoryIds` map to `CategoryOverTimeData` for proper category resolution in the over-time chart
## 0.2.2
- Bump version
## 0.2.1
- Add "All Keywords" view on Categories page
- Add dark mode with warm gray palette
- Fix orphan categories, persist has_header for imports, add re-initialize
- Add Budget and Adjustments pages

View file

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

2
src-tauri/Cargo.lock generated
View file

@ -3770,7 +3770,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
[[package]]
name = "simpl-result"
version = "0.1.0"
version = "0.2.3"
dependencies = [
"encoding_rs",
"serde",

View file

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

View file

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

View file

@ -1,13 +1,38 @@
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
import { Eye } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types";
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu";
interface CategoryPieChartProps {
data: CategoryBreakdownItem[];
hiddenCategories: Set<string>;
onToggleHidden: (categoryName: string) => void;
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
}
export default function CategoryPieChart({ data }: CategoryPieChartProps) {
export default function CategoryPieChart({
data,
hiddenCategories,
onToggleHidden,
onShowAll,
onViewDetails,
}: CategoryPieChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
const total = visibleData.reduce((sum, d) => sum + d.total, 0);
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!hoveredRef.current) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item: hoveredRef.current });
}, []);
if (data.length === 0) {
return (
@ -18,55 +43,109 @@ export default function CategoryPieChart({ data }: CategoryPieChartProps) {
);
}
const total = data.reduce((sum, d) => sum + d.total, 0);
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<Pie
data={data}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={100}
paddingAngle={2}
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-3">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{data.map((item, index) => (
<Cell key={index} fill={item.category_color} />
))}
</Pie>
<Tooltip
formatter={(value) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
</PieChart>
</ResponsiveContainer>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => (
<div key={index} className="flex items-center gap-1.5 text-sm">
<span
className="w-3 h-3 rounded-full inline-block flex-shrink-0"
style={{ backgroundColor: item.category_color }}
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={280}>
<PieChart>
<ChartPatternDefs
prefix="cat-pie"
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
/>
<span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</div>
))}
<Pie
data={visibleData}
dataKey="total"
nameKey="category_name"
cx="50%"
cy="50%"
innerRadius={50}
outerRadius={100}
paddingAngle={2}
>
{visibleData.map((item, index) => (
<Cell
key={index}
fill={getPatternFill("cat-pie", index, item.category_color)}
onMouseEnter={() => { hoveredRef.current = item; }}
onMouseLeave={() => { hoveredRef.current = null; }}
cursor="context-menu"
/>
))}
</Pie>
<Tooltip
formatter={(value) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
</PieChart>
</ResponsiveContainer>
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 mt-2">
{data.map((item, index) => {
const isHidden = hiddenCategories.has(item.category_name);
return (
<button
key={index}
className={`flex items-center gap-1.5 text-sm ${isHidden ? "opacity-40" : ""}`}
onContextMenu={(e) => {
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item });
}}
onClick={() => isHidden ? onToggleHidden(item.category_name) : undefined}
title={isHidden ? t("charts.clickToShow") : undefined}
>
<PatternSwatch index={index} color={item.category_color} prefix="cat-pie" />
<span className="text-[var(--muted-foreground)]">
{item.category_name} {total > 0 && !isHidden ? `${Math.round((item.total / total) * 100)}%` : ""}
</span>
</button>
);
})}
</div>
{contextMenu && (
<ChartContextMenu
x={contextMenu.x}
y={contextMenu.y}
categoryName={contextMenu.item.category_name}
onHide={() => onToggleHidden(contextMenu.item.category_name)}
onViewDetails={() => onViewDetails(contextMenu.item)}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,61 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X } from "lucide-react";
import FilePreviewTable from "./FilePreviewTable";
import type { ParsedRow } from "../../shared/types";
interface FilePreviewModalProps {
rows: ParsedRow[];
totalCount: number;
onClose: () => void;
}
export default function FilePreviewModal({
rows,
totalCount,
onClose,
}: FilePreviewModalProps) {
const { t } = useTranslation();
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose]);
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-4xl max-h-[85vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<h2 className="text-lg font-semibold">{t("import.preview.title")}</h2>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto p-6">
<FilePreviewTable rows={rows} />
{totalCount > rows.length && (
<p className="text-sm text-[var(--muted-foreground)] text-center mt-4">
{t("import.preview.moreRows", {
count: totalCount - rows.length,
})}
</p>
)}
</div>
</div>
</div>,
document.body
);
}

View file

@ -1,3 +1,4 @@
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart,
@ -8,17 +9,40 @@ import {
ResponsiveContainer,
Cell,
} from "recharts";
import { Eye } from "lucide-react";
import type { CategoryBreakdownItem } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
interface CategoryBarChartProps {
data: CategoryBreakdownItem[];
hiddenCategories: Set<string>;
onToggleHidden: (categoryName: string) => void;
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
}
export default function CategoryBarChart({ data }: CategoryBarChartProps) {
export default function CategoryBarChart({
data,
hiddenCategories,
onToggleHidden,
onShowAll,
onViewDetails,
}: CategoryBarChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<CategoryBreakdownItem | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; item: CategoryBreakdownItem } | null>(null);
const visibleData = data.filter((d) => !hiddenCategories.has(d.category_name));
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!hoveredRef.current) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, item: hoveredRef.current });
}, []);
if (data.length === 0) {
return (
@ -30,39 +54,84 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) {
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={Math.max(400, data.length * 40)}>
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<XAxis
type="number"
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
type="category"
dataKey="category_name"
width={120}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
{data.map((item, index) => (
<Cell key={index} fill={item.category_color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={Math.max(400, visibleData.length * 40)}>
<BarChart data={visibleData} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
<ChartPatternDefs
prefix="cat-bar"
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
/>
<XAxis
type="number"
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
type="category"
dataKey="category_name"
width={120}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
{visibleData.map((item, index) => (
<Cell
key={index}
fill={getPatternFill("cat-bar", index, item.category_color)}
onMouseEnter={() => { hoveredRef.current = item; }}
onMouseLeave={() => { hoveredRef.current = null; }}
cursor="context-menu"
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
{contextMenu && (
<ChartContextMenu
x={contextMenu.x}
y={contextMenu.y}
categoryName={contextMenu.item.category_name}
onHide={() => onToggleHidden(contextMenu.item.category_name)}
onViewDetails={() => onViewDetails(contextMenu.item)}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}

View file

@ -1,3 +1,4 @@
import { useState, useRef, useCallback } from "react";
import { useTranslation } from "react-i18next";
import {
BarChart,
@ -9,7 +10,10 @@ import {
Legend,
CartesianGrid,
} from "recharts";
import type { CategoryOverTimeData } from "../../shared/types";
import { Eye } from "lucide-react";
import type { CategoryOverTimeData, CategoryBreakdownItem } from "../../shared/types";
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
import ChartContextMenu from "../shared/ChartContextMenu";
const cadFormatter = (value: number) =>
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
@ -22,10 +26,35 @@ function formatMonth(month: string): string {
interface CategoryOverTimeChartProps {
data: CategoryOverTimeData;
hiddenCategories: Set<string>;
onToggleHidden: (categoryName: string) => void;
onShowAll: () => void;
onViewDetails: (item: CategoryBreakdownItem) => void;
}
export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartProps) {
export default function CategoryOverTimeChart({
data,
hiddenCategories,
onToggleHidden,
onShowAll,
onViewDetails,
}: CategoryOverTimeChartProps) {
const { t } = useTranslation();
const hoveredRef = useRef<string | null>(null);
const [contextMenu, setContextMenu] = useState<{ x: number; y: number; name: string } | null>(null);
const visibleCategories = data.categories.filter((name) => !hiddenCategories.has(name));
const categoryEntries = visibleCategories.map((name, index) => ({
name,
color: data.colors[name],
index,
}));
const handleContextMenu = useCallback((e: React.MouseEvent) => {
if (!hoveredRef.current) return;
e.preventDefault();
setContextMenu({ x: e.clientX, y: e.clientY, name: hoveredRef.current });
}, []);
if (data.data.length === 0) {
return (
@ -37,44 +66,95 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro
return (
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{data.categories.map((name) => (
<Bar
{hiddenCategories.size > 0 && (
<div className="flex flex-wrap items-center gap-2 mb-4">
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
{Array.from(hiddenCategories).map((name) => (
<button
key={name}
dataKey={name}
stackId="stack"
fill={data.colors[name]}
/>
onClick={() => onToggleHidden(name)}
className="inline-flex items-center gap-1 px-2 py-0.5 text-xs rounded-full bg-[var(--muted)] text-[var(--muted-foreground)] hover:bg-[var(--border)] transition-colors"
>
<Eye size={12} />
{name}
</button>
))}
</BarChart>
</ResponsiveContainer>
<button
onClick={onShowAll}
className="text-xs text-[var(--primary)] hover:underline"
>
{t("charts.showAll")}
</button>
</div>
)}
<div onContextMenu={handleContextMenu}>
<ResponsiveContainer width="100%" height={400}>
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
<ChartPatternDefs
prefix="cat-time"
categories={categoryEntries.map((c) => ({ color: c.color, index: c.index }))}
/>
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
<XAxis
dataKey="month"
tickFormatter={formatMonth}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
/>
<YAxis
tickFormatter={(v) => cadFormatter(v)}
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
stroke="var(--border)"
width={80}
/>
<Tooltip
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
labelFormatter={(label) => formatMonth(String(label))}
contentStyle={{
backgroundColor: "var(--card)",
border: "1px solid var(--border)",
borderRadius: "8px",
color: "var(--foreground)",
}}
labelStyle={{ color: "var(--foreground)" }}
itemStyle={{ color: "var(--foreground)" }}
/>
<Legend />
{categoryEntries.map((c) => (
<Bar
key={c.name}
dataKey={c.name}
stackId="stack"
fill={getPatternFill("cat-time", c.index, c.color)}
onMouseEnter={() => { hoveredRef.current = c.name; }}
onMouseLeave={() => { hoveredRef.current = null; }}
cursor="context-menu"
/>
))}
</BarChart>
</ResponsiveContainer>
</div>
{contextMenu && (
<ChartContextMenu
x={contextMenu.x}
y={contextMenu.y}
categoryName={contextMenu.name}
onHide={() => onToggleHidden(contextMenu.name)}
onViewDetails={() => {
const color = data.colors[contextMenu.name] || "#9ca3af";
const categoryId = data.categoryIds[contextMenu.name] ?? null;
onViewDetails({
category_id: categoryId,
category_name: contextMenu.name,
category_color: color,
total: 0,
});
}}
onClose={() => setContextMenu(null)}
/>
)}
</div>
);
}

View file

@ -0,0 +1,79 @@
import { useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { EyeOff, List } from "lucide-react";
export interface ChartContextMenuProps {
x: number;
y: number;
categoryName: string;
onHide: () => void;
onViewDetails: () => void;
onClose: () => void;
}
export default function ChartContextMenu({
x,
y,
categoryName,
onHide,
onViewDetails,
onClose,
}: ChartContextMenuProps) {
const { t } = useTranslation();
const menuRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose();
}
}
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("mousedown", handleClickOutside);
document.addEventListener("keydown", handleEscape);
return () => {
document.removeEventListener("mousedown", handleClickOutside);
document.removeEventListener("keydown", handleEscape);
};
}, [onClose]);
// Adjust position to stay within viewport
useEffect(() => {
if (!menuRef.current) return;
const rect = menuRef.current.getBoundingClientRect();
if (rect.right > window.innerWidth) {
menuRef.current.style.left = `${x - rect.width}px`;
}
if (rect.bottom > window.innerHeight) {
menuRef.current.style.top = `${y - rect.height}px`;
}
}, [x, y]);
return (
<div
ref={menuRef}
className="fixed z-[100] min-w-[180px] bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg py-1"
style={{ left: x, top: y }}
>
<div className="px-3 py-1.5 text-xs font-medium text-[var(--muted-foreground)] truncate border-b border-[var(--border)]">
{categoryName}
</div>
<button
onClick={() => { onViewDetails(); onClose(); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<List size={14} />
{t("charts.viewTransactions")}
</button>
<button
onClick={() => { onHide(); onClose(); }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<EyeOff size={14} />
{t("charts.hideCategory")}
</button>
</div>
);
}

View file

@ -0,0 +1,145 @@
import { useEffect, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { X, Loader2 } from "lucide-react";
import { getTransactionsByCategory } from "../../services/dashboardService";
import type { TransactionRow } from "../../shared/types";
const cadFormatter = new Intl.NumberFormat("en-CA", {
style: "currency",
currency: "CAD",
});
interface TransactionDetailModalProps {
categoryId: number | null;
categoryName: string;
categoryColor: string;
dateFrom?: string;
dateTo?: string;
onClose: () => void;
}
export default function TransactionDetailModal({
categoryId,
categoryName,
categoryColor,
dateFrom,
dateTo,
onClose,
}: TransactionDetailModalProps) {
const { t } = useTranslation();
const [rows, setRows] = useState<TransactionRow[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
const data = await getTransactionsByCategory(categoryId, dateFrom, dateTo);
setRows(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setIsLoading(false);
}
}, [categoryId, dateFrom, dateTo]);
useEffect(() => {
fetchData();
}, [fetchData]);
useEffect(() => {
function handleEscape(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", handleEscape);
return () => document.removeEventListener("keydown", handleEscape);
}, [onClose]);
const total = rows.reduce((sum, r) => sum + r.amount, 0);
return createPortal(
<div
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/50"
onClick={(e) => { if (e.target === e.currentTarget) onClose(); }}
>
<div className="bg-[var(--card)] rounded-xl border border-[var(--border)] shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col mx-4">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border)]">
<div className="flex items-center gap-3">
<span
className="w-4 h-4 rounded-full inline-block flex-shrink-0"
style={{ backgroundColor: categoryColor }}
/>
<h2 className="text-lg font-semibold">{categoryName}</h2>
<span className="text-sm text-[var(--muted-foreground)]">
({rows.length} {t("charts.transactions")})
</span>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg hover:bg-[var(--muted)] transition-colors"
>
<X size={18} />
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-auto">
{isLoading && (
<div className="flex items-center justify-center py-12">
<Loader2 size={24} className="animate-spin text-[var(--muted-foreground)]" />
</div>
)}
{error && (
<div className="px-6 py-4 text-[var(--negative)]">{error}</div>
)}
{!isLoading && !error && rows.length === 0 && (
<div className="px-6 py-8 text-center text-[var(--muted-foreground)]">
{t("dashboard.noData")}
</div>
)}
{!isLoading && !error && rows.length > 0 && (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-[var(--border)] text-[var(--muted-foreground)]">
<th className="text-left px-6 py-2 font-medium">{t("transactions.date")}</th>
<th className="text-left px-6 py-2 font-medium">{t("transactions.description")}</th>
<th className="text-right px-6 py-2 font-medium">{t("transactions.amount")}</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-[var(--border)] hover:bg-[var(--muted)]">
<td className="px-6 py-2 whitespace-nowrap">{row.date}</td>
<td className="px-6 py-2 truncate max-w-[300px]">{row.description}</td>
<td className={`px-6 py-2 text-right whitespace-nowrap font-medium ${
row.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}>
{cadFormatter.format(row.amount)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="font-semibold">
<td className="px-6 py-3" colSpan={2}>{t("charts.total")}</td>
<td className={`px-6 py-3 text-right ${
total >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}>
{cadFormatter.format(total)}
</td>
</tr>
</tfoot>
</table>
)}
</div>
</div>
</div>,
document.body
);
}

View file

@ -418,6 +418,101 @@ export function useImportWizard() {
}
}, [state.selectedSource]);
// Internal helper: parses selected files and returns rows + headers
const parseFilesInternal = useCallback(async (): Promise<{ rows: ParsedRow[]; headers: string[] }> => {
const config = state.sourceConfig;
const allRows: ParsedRow[] = [];
let headers: string[] = [];
for (const file of state.selectedFiles) {
const content = await invoke<string>("read_file_content", {
filePath: file.file_path,
encoding: config.encoding,
});
const preprocessed = preprocessQuotedCSV(content);
const parsed = Papa.parse(preprocessed, {
delimiter: config.delimiter,
skipEmptyLines: true,
});
const data = parsed.data as string[][];
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
if (config.hasHeader && data.length > config.skipLines) {
headers = data[config.skipLines].map((h) => h.trim());
} else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
const firstDataRow = data[config.skipLines];
headers = firstDataRow.map((_, i) => `Col ${i}`);
}
for (let i = startIdx; i < data.length; i++) {
const raw = data[i];
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
try {
const date = parseDate(
raw[config.columnMapping.date]?.trim() || "",
config.dateFormat
);
const description =
raw[config.columnMapping.description]?.trim() || "";
let amount: number;
if (config.amountMode === "debit_credit") {
const debit = parseFrenchAmount(
raw[config.columnMapping.debitAmount ?? 0] || ""
);
const credit = parseFrenchAmount(
raw[config.columnMapping.creditAmount ?? 0] || ""
);
amount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit;
} else {
amount = parseFrenchAmount(
raw[config.columnMapping.amount ?? 0] || ""
);
if (config.signConvention === "positive_expense" && !isNaN(amount)) {
amount = -amount;
}
}
if (!date) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid date",
});
} else if (isNaN(amount)) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid amount",
});
} else {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: { date, description, amount },
});
}
} catch {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Parse error",
});
}
}
}
return { rows: allRows, headers };
}, [state.selectedFiles, state.sourceConfig]);
// Parse files and store preview (does NOT change wizard step)
const parsePreview = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
@ -425,220 +520,134 @@ export function useImportWizard() {
dispatch({ type: "SET_ERROR", payload: null });
try {
const config = state.sourceConfig;
const allRows: ParsedRow[] = [];
let headers: string[] = [];
for (const file of state.selectedFiles) {
const content = await invoke<string>("read_file_content", {
filePath: file.file_path,
encoding: config.encoding,
});
const preprocessed = preprocessQuotedCSV(content);
const parsed = Papa.parse(preprocessed, {
delimiter: config.delimiter,
skipEmptyLines: true,
});
const data = parsed.data as string[][];
const startIdx = config.skipLines + (config.hasHeader ? 1 : 0);
if (config.hasHeader && data.length > config.skipLines) {
headers = data[config.skipLines].map((h) => h.trim());
} else if (!config.hasHeader && headers.length === 0 && data.length > config.skipLines) {
const firstDataRow = data[config.skipLines];
headers = firstDataRow.map((_, i) => `Col ${i}`);
}
for (let i = startIdx; i < data.length; i++) {
const raw = data[i];
if (raw.length <= 1 && raw[0]?.trim() === "") continue;
try {
const date = parseDate(
raw[config.columnMapping.date]?.trim() || "",
config.dateFormat
);
const description =
raw[config.columnMapping.description]?.trim() || "";
let amount: number;
if (config.amountMode === "debit_credit") {
const debit = parseFrenchAmount(
raw[config.columnMapping.debitAmount ?? 0] || ""
);
const credit = parseFrenchAmount(
raw[config.columnMapping.creditAmount ?? 0] || ""
);
amount = isNaN(credit) ? -(isNaN(debit) ? 0 : debit) : credit;
} else {
amount = parseFrenchAmount(
raw[config.columnMapping.amount ?? 0] || ""
);
if (config.signConvention === "positive_expense" && !isNaN(amount)) {
amount = -amount;
}
}
if (!date) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid date",
});
} else if (isNaN(amount)) {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Invalid amount",
});
} else {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: { date, description, amount },
});
}
} catch {
allRows.push({
rowIndex: allRows.length,
raw,
parsed: null,
error: "Parse error",
});
}
}
}
const result = await parseFilesInternal();
dispatch({
type: "SET_PARSED_PREVIEW",
payload: { rows: allRows, headers },
payload: result,
});
dispatch({ type: "SET_STEP", payload: "file-preview" });
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.selectedFiles, state.sourceConfig]);
}, [state.selectedFiles, parseFilesInternal]);
// Internal helper: runs duplicate checking against parsed rows
const checkDuplicatesInternal = useCallback(async (parsedRows: ParsedRow[]) => {
// Save/update source config in DB
const config = state.sourceConfig;
const mappingJson = JSON.stringify(config.columnMapping);
let sourceId: number;
if (state.existingSource) {
sourceId = state.existingSource.id;
await updateSource(sourceId, {
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
} else {
sourceId = await createSource({
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
}
// Check file-level duplicates
let fileAlreadyImported = false;
let existingFileId: number | undefined;
if (state.selectedFiles.length > 0) {
const hash = await invoke<string>("hash_file", {
filePath: state.selectedFiles[0].file_path,
});
const existing = await existsByHash(hash);
if (existing) {
fileAlreadyImported = true;
existingFileId = existing.id;
}
}
// Check row-level duplicates
const validRows = parsedRows.filter((r) => r.parsed);
const duplicateMatches = await findDuplicates(
validRows.map((r) => ({
date: r.parsed!.date,
description: r.parsed!.description,
amount: r.parsed!.amount,
}))
);
const duplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
const newRows = validRows.filter(
(_, i) => !duplicateIndices.has(i)
);
const duplicateRows = duplicateMatches.map((d) => ({
rowIndex: d.rowIndex,
date: d.date,
description: d.description,
amount: d.amount,
existingTransactionId: d.existingTransactionId,
}));
dispatch({
type: "SET_DUPLICATE_RESULT",
payload: {
fileAlreadyImported,
existingFileId,
duplicateRows,
newRows,
},
});
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
}, [state.sourceConfig, state.existingSource, state.selectedFiles]);
// Check duplicates using already-parsed preview data
const checkDuplicates = useCallback(async () => {
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
// Save/update source config in DB
const config = state.sourceConfig;
const mappingJson = JSON.stringify(config.columnMapping);
let sourceId: number;
if (state.existingSource) {
sourceId = state.existingSource.id;
await updateSource(sourceId, {
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
} else {
sourceId = await createSource({
name: config.name,
delimiter: config.delimiter,
encoding: config.encoding,
date_format: config.dateFormat,
column_mapping: mappingJson,
skip_lines: config.skipLines,
has_header: config.hasHeader,
});
}
// Check file-level duplicates
let fileAlreadyImported = false;
let existingFileId: number | undefined;
if (state.selectedFiles.length > 0) {
const hash = await invoke<string>("hash_file", {
filePath: state.selectedFiles[0].file_path,
});
const existing = await existsByHash(hash);
if (existing) {
fileAlreadyImported = true;
existingFileId = existing.id;
}
}
// Check row-level duplicates
const validRows = state.parsedPreview.filter((r) => r.parsed);
const duplicateMatches = await findDuplicates(
validRows.map((r) => ({
date: r.parsed!.date,
description: r.parsed!.description,
amount: r.parsed!.amount,
}))
);
// Detect intra-batch duplicates (rows that appear more than once within the import)
const dbDuplicateIndices = new Set(duplicateMatches.map((d) => d.rowIndex));
const seenKeys = new Set<string>();
const batchDuplicateIndices = new Set<number>();
for (let i = 0; i < validRows.length; i++) {
if (dbDuplicateIndices.has(i)) continue; // already flagged as DB duplicate
const r = validRows[i].parsed!;
const key = `${r.date}|${r.description}|${r.amount}`;
if (seenKeys.has(key)) {
batchDuplicateIndices.add(i);
} else {
seenKeys.add(key);
}
}
const duplicateIndices = new Set([...dbDuplicateIndices, ...batchDuplicateIndices]);
const newRows = validRows.filter(
(_, i) => !duplicateIndices.has(i)
);
const duplicateRows = [
...duplicateMatches.map((d) => ({
rowIndex: d.rowIndex,
date: d.date,
description: d.description,
amount: d.amount,
existingTransactionId: d.existingTransactionId,
})),
...[...batchDuplicateIndices].map((i) => ({
rowIndex: i,
date: validRows[i].parsed!.date,
description: validRows[i].parsed!.description,
amount: validRows[i].parsed!.amount,
existingTransactionId: -1,
})),
];
dispatch({
type: "SET_DUPLICATE_RESULT",
payload: {
fileAlreadyImported,
existingFileId,
duplicateRows,
newRows,
},
});
dispatch({ type: "SET_STEP", payload: "duplicate-check" });
await checkDuplicatesInternal(state.parsedPreview);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.sourceConfig, state.existingSource, state.selectedFiles, state.parsedPreview]);
}, [state.parsedPreview, checkDuplicatesInternal]);
// Parse files then check duplicates in one step (skips preview step)
const parseAndCheckDuplicates = useCallback(async () => {
if (state.selectedFiles.length === 0) return;
dispatch({ type: "SET_LOADING", payload: true });
dispatch({ type: "SET_ERROR", payload: null });
try {
const result = await parseFilesInternal();
dispatch({
type: "SET_PARSED_PREVIEW",
payload: result,
});
await checkDuplicatesInternal(result.rows);
} catch (e) {
dispatch({
type: "SET_ERROR",
payload: e instanceof Error ? e.message : String(e),
});
}
}, [state.selectedFiles, parseFilesInternal, checkDuplicatesInternal]);
const executeImport = useCallback(async () => {
if (!state.duplicateResult) return;
@ -846,6 +855,7 @@ export function useImportWizard() {
selectAllFiles,
parsePreview,
checkDuplicates,
parseAndCheckDuplicates,
executeImport,
goToStep,
reset,

View file

@ -33,7 +33,7 @@ const initialState: ReportsState = {
period: "6months",
monthlyTrends: [],
categorySpending: [],
categoryOverTime: { categories: [], data: [], colors: {} },
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
isLoading: false,
error: null,
};

View file

@ -352,6 +352,15 @@
]
}
},
"charts": {
"hideCategory": "Hide category",
"viewTransactions": "View transactions",
"hiddenCategories": "Hidden",
"showAll": "Show all",
"total": "Total",
"transactions": "transactions",
"clickToShow": "Click to show"
},
"common": {
"save": "Save",
"cancel": "Cancel",

View file

@ -352,6 +352,15 @@
]
}
},
"charts": {
"hideCategory": "Masquer la catégorie",
"viewTransactions": "Voir les transactions",
"hiddenCategories": "Masquées",
"showAll": "Tout afficher",
"total": "Total",
"transactions": "transactions",
"clickToShow": "Cliquer pour afficher"
},
"common": {
"save": "Enregistrer",
"cancel": "Annuler",

View file

@ -1,3 +1,4 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
import { useDashboard } from "../hooks/useDashboard";
@ -5,14 +6,52 @@ import { PageHelp } from "../components/shared/PageHelp";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
import type { CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
const fmt = new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" });
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month": from = new Date(year, month, 1); break;
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 1); break;
case "12months": from = new Date(year, month - 11, 1); break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export default function DashboardPage() {
const { t } = useTranslation();
const { state, setPeriod } = useDashboard();
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
setDetailModal(item);
}, []);
const balance = summary.totalAmount;
const balanceColor =
balance > 0
@ -42,6 +81,8 @@ export default function DashboardPage() {
},
];
const { dateFrom, dateTo } = computeDateRange(period);
return (
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
@ -70,9 +111,26 @@ export default function DashboardPage() {
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<CategoryPieChart data={categoryBreakdown} />
<CategoryPieChart
data={categoryBreakdown}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
<RecentTransactionsList transactions={recentTransactions} />
</div>
{detailModal && (
<TransactionDetailModal
categoryId={detailModal.category_id}
categoryName={detailModal.category_name}
categoryColor={detailModal.category_color}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setDetailModal(null)}
/>
)}
</div>
);
}

View file

@ -1,16 +1,17 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useImportWizard } from "../hooks/useImportWizard";
import ImportFolderConfig from "../components/import/ImportFolderConfig";
import SourceList from "../components/import/SourceList";
import SourceConfigPanel from "../components/import/SourceConfigPanel";
import FilePreviewTable from "../components/import/FilePreviewTable";
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
import ImportConfirmation from "../components/import/ImportConfirmation";
import ImportProgress from "../components/import/ImportProgress";
import ImportReportPanel from "../components/import/ImportReportPanel";
import WizardNavigation from "../components/import/WizardNavigation";
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
import { AlertCircle } from "lucide-react";
import FilePreviewModal from "../components/import/FilePreviewModal";
import { AlertCircle, Eye, X, ChevronLeft } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp";
export default function ImportPage() {
@ -24,7 +25,7 @@ export default function ImportPage() {
toggleFile,
selectAllFiles,
parsePreview,
checkDuplicates,
parseAndCheckDuplicates,
executeImport,
goToStep,
reset,
@ -33,6 +34,15 @@ export default function ImportPage() {
setSkipAllDuplicates,
} = useImportWizard();
const [showPreviewModal, setShowPreviewModal] = useState(false);
const handlePreview = useCallback(async () => {
await parsePreview();
setShowPreviewModal(true);
}, [parsePreview]);
const nextDisabled = state.selectedFiles.length === 0 || !state.sourceConfig.name;
return (
<div>
<div className="relative flex items-center gap-3 mb-6">
@ -84,39 +94,41 @@ export default function ImportPage() {
onAutoDetect={autoDetectConfig}
isLoading={state.isLoading}
/>
<WizardNavigation
onBack={() => goToStep("source-list")}
onNext={parsePreview}
onCancel={reset}
nextLabel={t("import.wizard.preview")}
nextDisabled={
state.selectedFiles.length === 0 || !state.sourceConfig.name
}
/>
</div>
)}
{state.step === "file-preview" && (
<div className="space-y-6">
<FilePreviewTable
rows={state.parsedPreview.slice(0, 20)}
/>
{state.parsedPreview.length > 20 && (
<p className="text-sm text-[var(--muted-foreground)] text-center">
{t("import.preview.moreRows", {
count: state.parsedPreview.length - 20,
})}
</p>
)}
<WizardNavigation
onBack={() => goToStep("source-config")}
onNext={checkDuplicates}
onCancel={reset}
nextLabel={t("import.wizard.checkDuplicates")}
nextDisabled={
state.parsedPreview.filter((r) => r.parsed).length === 0
}
/>
<div className="flex items-center justify-between pt-6 border-t border-[var(--border)]">
<div>
<button
onClick={reset}
className="flex items-center gap-1 px-4 py-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
>
<X size={16} />
{t("common.cancel")}
</button>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => goToStep("source-list")}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
>
<ChevronLeft size={16} />
{t("import.wizard.back")}
</button>
<button
onClick={handlePreview}
disabled={nextDisabled || state.isLoading}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye size={16} />
{t("import.wizard.preview")}
</button>
<button
onClick={parseAndCheckDuplicates}
disabled={nextDisabled || state.isLoading}
className="flex items-center gap-1 px-4 py-2 text-sm rounded-lg bg-[var(--primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
>
{t("import.wizard.checkDuplicates")}
</button>
</div>
</div>
</div>
)}
@ -130,7 +142,7 @@ export default function ImportPage() {
onIncludeAll={() => setSkipAllDuplicates(false)}
/>
<WizardNavigation
onBack={() => goToStep("file-preview")}
onBack={() => goToStep("source-config")}
onNext={() => goToStep("confirm")}
onCancel={reset}
nextLabel={t("import.wizard.confirm")}
@ -168,6 +180,15 @@ export default function ImportPage() {
{state.step === "report" && state.importReport && (
<ImportReportPanel report={state.importReport} onDone={reset} />
)}
{/* Preview modal */}
{showPreviewModal && state.parsedPreview.length > 0 && (
<FilePreviewModal
rows={state.parsedPreview.slice(0, 20)}
totalCount={state.parsedPreview.length}
onClose={() => setShowPreviewModal(false)}
/>
)}
</div>
);
}

View file

@ -1,18 +1,58 @@
import { useState, useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useReports } from "../hooks/useReports";
import { PageHelp } from "../components/shared/PageHelp";
import type { ReportTab } from "../shared/types";
import type { ReportTab, CategoryBreakdownItem, DashboardPeriod } from "../shared/types";
import PeriodSelector from "../components/dashboard/PeriodSelector";
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
import CategoryBarChart from "../components/reports/CategoryBarChart";
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"];
function computeDateRange(period: DashboardPeriod): { dateFrom?: string; dateTo?: string } {
if (period === "all") return {};
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth();
const day = now.getDate();
const dateTo = `${year}-${String(month + 1).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
let from: Date;
switch (period) {
case "month": from = new Date(year, month, 1); break;
case "3months": from = new Date(year, month - 2, 1); break;
case "6months": from = new Date(year, month - 5, 1); break;
case "12months": from = new Date(year, month - 11, 1); break;
}
const dateFrom = `${from.getFullYear()}-${String(from.getMonth() + 1).padStart(2, "0")}-${String(from.getDate()).padStart(2, "0")}`;
return { dateFrom, dateTo };
}
export default function ReportsPage() {
const { t } = useTranslation();
const { state, setTab, setPeriod } = useReports();
const [hiddenCategories, setHiddenCategories] = useState<Set<string>>(new Set());
const [detailModal, setDetailModal] = useState<CategoryBreakdownItem | null>(null);
const toggleHidden = useCallback((name: string) => {
setHiddenCategories((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
}, []);
const showAll = useCallback(() => setHiddenCategories(new Set()), []);
const viewDetails = useCallback((item: CategoryBreakdownItem) => {
setDetailModal(item);
}, []);
const { dateFrom, dateTo } = computeDateRange(state.period);
return (
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
<div className="relative flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-6">
@ -46,8 +86,35 @@ export default function ReportsPage() {
)}
{state.tab === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
{state.tab === "byCategory" && <CategoryBarChart data={state.categorySpending} />}
{state.tab === "overTime" && <CategoryOverTimeChart data={state.categoryOverTime} />}
{state.tab === "byCategory" && (
<CategoryBarChart
data={state.categorySpending}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
)}
{state.tab === "overTime" && (
<CategoryOverTimeChart
data={state.categoryOverTime}
hiddenCategories={hiddenCategories}
onToggleHidden={toggleHidden}
onShowAll={showAll}
onViewDetails={viewDetails}
/>
)}
{detailModal && (
<TransactionDetailModal
categoryId={detailModal.category_id}
categoryName={detailModal.category_name}
categoryColor={detailModal.category_color}
dateFrom={dateFrom}
dateTo={dateTo}
onClose={() => setDetailModal(null)}
/>
)}
</div>
);
}

View file

@ -3,6 +3,7 @@ import type {
DashboardSummary,
CategoryBreakdownItem,
RecentTransaction,
TransactionRow,
} from "../shared/types";
export async function getDashboardSummary(
@ -88,6 +89,56 @@ export async function getExpensesByCategory(
);
}
export async function getTransactionsByCategory(
categoryId: number | null,
dateFrom?: string,
dateTo?: string
): Promise<TransactionRow[]> {
const db = await getDb();
const whereClauses: string[] = [];
const params: unknown[] = [];
let paramIndex = 1;
if (categoryId === null) {
whereClauses.push("t.category_id IS NULL");
} else {
whereClauses.push(`t.category_id = $${paramIndex}`);
params.push(categoryId);
paramIndex++;
}
if (dateFrom) {
whereClauses.push(`t.date >= $${paramIndex}`);
params.push(dateFrom);
paramIndex++;
}
if (dateTo) {
whereClauses.push(`t.date <= $${paramIndex}`);
params.push(dateTo);
paramIndex++;
}
const whereSQL = `WHERE ${whereClauses.join(" AND ")}`;
return db.select<TransactionRow[]>(
`SELECT
t.id, t.date, t.description, t.amount,
t.category_id,
c.name AS category_name,
c.color AS category_color,
s.name AS source_name,
t.notes,
t.is_manually_categorized
FROM transactions t
LEFT JOIN categories c ON t.category_id = c.id
LEFT JOIN import_sources s ON t.source_id = s.id
${whereSQL}
ORDER BY t.date DESC, t.id DESC`,
params
);
}
export async function getRecentTransactions(
limit: number = 10
): Promise<RecentTransaction[]> {

View file

@ -85,8 +85,10 @@ export async function getCategoryOverTime(
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
const colors: Record<string, string> = {};
const categoryIds: Record<string, number | null> = {};
for (const cat of topCategories) {
colors[cat.category_name] = cat.category_color;
categoryIds[cat.category_name] = cat.category_id;
}
// Get monthly breakdown for all categories
@ -142,5 +144,6 @@ export async function getCategoryOverTime(
categories,
data: Array.from(monthMap.values()),
colors,
categoryIds,
};
}

View file

@ -264,6 +264,7 @@ export interface CategoryOverTimeData {
categories: string[];
data: CategoryOverTimeItem[];
colors: Record<string, string>;
categoryIds: Record<string, number | null>;
}
export type ImportWizardStep =

149
src/utils/chartPatterns.tsx Normal file
View file

@ -0,0 +1,149 @@
import React from "react";
// Pattern generators: each returns the SVG content for a <pattern> element.
// The pattern uses the category color as the base fill and adds a white overlay texture.
const patternGenerators: ((color: string) => React.ReactNode)[] = [
// 0: Solid — no overlay
() => null,
// 1: Diagonal lines (45°)
() => (
<line x1="0" y1="0" x2="8" y2="8" stroke="rgba(255,255,255,0.55)" strokeWidth="2" />
),
// 2: Dots
() => (
<>
<circle cx="2" cy="2" r="1.5" fill="rgba(255,255,255,0.55)" />
<circle cx="6" cy="6" r="1.5" fill="rgba(255,255,255,0.55)" />
</>
),
// 3: Crosshatch
() => (
<>
<line x1="0" y1="0" x2="8" y2="8" stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
<line x1="8" y1="0" x2="0" y2="8" stroke="rgba(255,255,255,0.55)" strokeWidth="1.5" />
</>
),
// 4: Horizontal lines
() => (
<line x1="0" y1="4" x2="8" y2="4" stroke="rgba(255,255,255,0.55)" strokeWidth="2" />
),
// 5: Vertical lines
() => (
<line x1="4" y1="0" x2="4" y2="8" stroke="rgba(255,255,255,0.55)" strokeWidth="2" />
),
// 6: Reverse diagonal (135°)
() => (
<line x1="8" y1="0" x2="0" y2="8" stroke="rgba(255,255,255,0.55)" strokeWidth="2" />
),
// 7: Dense dots
() => (
<>
<circle cx="1" cy="1" r="1" fill="rgba(255,255,255,0.55)" />
<circle cx="5" cy="1" r="1" fill="rgba(255,255,255,0.55)" />
<circle cx="3" cy="5" r="1" fill="rgba(255,255,255,0.55)" />
<circle cx="7" cy="5" r="1" fill="rgba(255,255,255,0.55)" />
</>
),
];
/**
* Generates a unique pattern ID from a chart-scoped prefix and index.
*/
function patternId(prefix: string, index: number): string {
return `${prefix}-pattern-${index}`;
}
/**
* Returns the fill value for a category at the given index.
* Index 0 gets solid color; others get a pattern reference.
*/
export function getPatternFill(
prefix: string,
index: number,
color: string
): string {
const pIdx = index % patternGenerators.length;
if (pIdx === 0) return color;
return `url(#${patternId(prefix, index)})`;
}
interface ChartPatternDefsProps {
/** Unique prefix to avoid ID collisions when multiple charts are on screen */
prefix: string;
/** Array of { color, index } for each category that needs a pattern */
categories: { color: string; index: number }[];
}
/**
* Renders SVG <defs> with <pattern> elements for chart categories.
* Must be placed inside an SVG context (e.g. inside a Recharts customized component).
*/
export function ChartPatternDefs({ prefix, categories }: ChartPatternDefsProps) {
return (
<defs>
{categories.map(({ color, index }) => {
const pIdx = index % patternGenerators.length;
if (pIdx === 0) return null; // solid, no pattern needed
return (
<pattern
key={patternId(prefix, index)}
id={patternId(prefix, index)}
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill={color} />
{patternGenerators[pIdx](color)}
</pattern>
);
})}
</defs>
);
}
/**
* Renders a small SVG swatch for use in legends, showing the pattern+color.
*/
export function PatternSwatch({
index,
color,
prefix,
size = 12,
}: {
index: number;
color: string;
prefix: string;
size?: number;
}) {
const pIdx = index % patternGenerators.length;
return (
<svg
width={size}
height={size}
viewBox="0 0 12 12"
className="inline-block flex-shrink-0"
style={{ borderRadius: "50%" }}
>
{pIdx !== 0 && (
<defs>
<pattern
id={`swatch-${patternId(prefix, index)}`}
patternUnits="userSpaceOnUse"
width="8"
height="8"
>
<rect width="8" height="8" fill={color} />
{patternGenerators[pIdx](color)}
</pattern>
</defs>
)}
<circle
cx="6"
cy="6"
r="6"
fill={pIdx === 0 ? color : `url(#swatch-${patternId(prefix, index)})`}
/>
</svg>
);
}

View file

@ -1,21 +1,15 @@
# Task: Fix orphan categories + add re-initialize button
## Root Cause (orphan categories)
`deactivateCategory` ran `SET is_active = 0 WHERE id = $1 OR parent_id = $1`, which silently
deactivated ALL children when a parent was deleted — even children that had transactions assigned.
Since `getAllCategoriesWithCounts` filters `WHERE is_active = 1`, those children vanished from the UI
with no way to recover them.
# Task: Import preview as popup + direct skip to duplicate check
## Plan
- [x] Fix `deactivateCategory`: promote children to root, only deactivate the parent itself
- [x] Add `getChildrenUsageCount` to block deletion when children have transactions
- [x] Add `reinitializeCategories` service function (re-runs seed data)
- [x] Add `reinitializeCategories` to hook
- [x] Add re-initialize button with confirmation on CategoriesPage
- [x] Add i18n keys (en + fr)
- [x] Update deleteConfirm/deleteBlocked messages to reflect new behavior
- [x] `npm run build` passes
- [x] Create `FilePreviewModal.tsx` — portal modal wrapping `FilePreviewTable`
- [x] Update `useImportWizard.ts` — extract `parseFilesInternal` helper, make `parsePreview` not change step, add `parseAndCheckDuplicates` combined function
- [x] Update `ImportPage.tsx` — source-config step has Preview button (opens modal) + Check Duplicates button (main action); remove file-preview step; duplicate-check back goes to source-config
- [x] Verify TypeScript compiles
## Progress Notes
- Extracted parsing logic into `parseFilesInternal` helper to avoid state closure issues when combining parse + duplicate check
- `checkDuplicatesInternal` takes parsed rows as parameter so `parseAndCheckDuplicates` can pass them directly
- No new i18n keys needed — reused existing ones
## Review
6 files changed. Orphan fix promotes children to root level instead of cascading deactivation.
Re-initialize button resets all categories+keywords to seed state (with user confirmation).
4 files changed/created. Preview is now a popup modal, file-preview wizard step is removed, "Check Duplicates" goes directly from source-config to duplicate-check (parsing files on the fly). Back navigation from duplicate-check returns to source-config.