feat: add chart patterns, context menu, and import preview popup (v0.2.3)
Some checks failed
Release / build (windows-latest) (push) Has been cancelled
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:
parent
def17d13fb
commit
29a1a15120
23 changed files with 1284 additions and 372 deletions
27
CHANGELOG.md
Normal file
27
CHANGELOG.md
Normal 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
|
||||
|
|
@ -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
2
src-tauri/Cargo.lock
generated
|
|
@ -3770,7 +3770,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
|||
|
||||
[[package]]
|
||||
name = "simpl-result"
|
||||
version = "0.1.0"
|
||||
version = "0.2.3"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
61
src/components/import/FilePreviewModal.tsx
Normal file
61
src/components/import/FilePreviewModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
79
src/components/shared/ChartContextMenu.tsx
Normal file
79
src/components/shared/ChartContextMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
145
src/components/shared/TransactionDetailModal.tsx
Normal file
145
src/components/shared/TransactionDetailModal.tsx
Normal 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
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ const initialState: ReportsState = {
|
|||
period: "6months",
|
||||
monthlyTrends: [],
|
||||
categorySpending: [],
|
||||
categoryOverTime: { categories: [], data: [], colors: {} },
|
||||
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||
isLoading: false,
|
||||
error: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]> {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
149
src/utils/chartPatterns.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue