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",
|
"name": "simpl_result_scaffold",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|
|
||||||
2
src-tauri/Cargo.lock
generated
2
src-tauri/Cargo.lock
generated
|
|
@ -3770,7 +3770,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.1.0"
|
version = "0.2.3"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"encoding_rs",
|
"encoding_rs",
|
||||||
"serde",
|
"serde",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[package]
|
[package]
|
||||||
name = "simpl-result"
|
name = "simpl-result"
|
||||||
version = "0.2.2"
|
version = "0.2.3"
|
||||||
description = "Personal finance management app"
|
description = "Personal finance management app"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Simpl Résultat",
|
"productName": "Simpl Résultat",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"identifier": "com.simpl.resultat",
|
"identifier": "com.simpl.resultat",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "npm run dev",
|
"beforeDevCommand": "npm run dev",
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,38 @@
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip } from "recharts";
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
import { ChartPatternDefs, getPatternFill, PatternSwatch } from "../../utils/chartPatterns";
|
||||||
|
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||||
|
|
||||||
interface CategoryPieChartProps {
|
interface CategoryPieChartProps {
|
||||||
data: CategoryBreakdownItem[];
|
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 { 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) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -18,55 +43,109 @@ export default function CategoryPieChart({ data }: CategoryPieChartProps) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const total = data.reduce((sum, d) => sum + d.total, 0);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<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>
|
<h2 className="text-lg font-semibold mb-4">{t("dashboard.expensesByCategory")}</h2>
|
||||||
<ResponsiveContainer width="100%" height={280}>
|
|
||||||
<PieChart>
|
{hiddenCategories.size > 0 && (
|
||||||
<Pie
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
data={data}
|
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||||
dataKey="total"
|
{Array.from(hiddenCategories).map((name) => (
|
||||||
nameKey="category_name"
|
<button
|
||||||
cx="50%"
|
key={name}
|
||||||
cy="50%"
|
onClick={() => onToggleHidden(name)}
|
||||||
innerRadius={50}
|
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"
|
||||||
outerRadius={100}
|
>
|
||||||
paddingAngle={2}
|
<Eye size={12} />
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
onClick={onShowAll}
|
||||||
|
className="text-xs text-[var(--primary)] hover:underline"
|
||||||
>
|
>
|
||||||
{data.map((item, index) => (
|
{t("charts.showAll")}
|
||||||
<Cell key={index} fill={item.category_color} />
|
</button>
|
||||||
))}
|
</div>
|
||||||
</Pie>
|
)}
|
||||||
<Tooltip
|
|
||||||
formatter={(value) =>
|
<div onContextMenu={handleContextMenu}>
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD" }).format(Number(value))
|
<ResponsiveContainer width="100%" height={280}>
|
||||||
}
|
<PieChart>
|
||||||
contentStyle={{
|
<ChartPatternDefs
|
||||||
backgroundColor: "var(--card)",
|
prefix="cat-pie"
|
||||||
border: "1px solid var(--border)",
|
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
|
||||||
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 }}
|
|
||||||
/>
|
/>
|
||||||
<span className="text-[var(--muted-foreground)]">
|
<Pie
|
||||||
{item.category_name} {total > 0 ? `${Math.round((item.total / total) * 100)}%` : ""}
|
data={visibleData}
|
||||||
</span>
|
dataKey="total"
|
||||||
</div>
|
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>
|
||||||
|
|
||||||
|
<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>
|
</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 { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
|
|
@ -8,17 +9,40 @@ import {
|
||||||
ResponsiveContainer,
|
ResponsiveContainer,
|
||||||
Cell,
|
Cell,
|
||||||
} from "recharts";
|
} from "recharts";
|
||||||
|
import { Eye } from "lucide-react";
|
||||||
import type { CategoryBreakdownItem } from "../../shared/types";
|
import type { CategoryBreakdownItem } from "../../shared/types";
|
||||||
|
import { ChartPatternDefs, getPatternFill } from "../../utils/chartPatterns";
|
||||||
|
import ChartContextMenu from "../shared/ChartContextMenu";
|
||||||
|
|
||||||
const cadFormatter = (value: number) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
||||||
interface CategoryBarChartProps {
|
interface CategoryBarChartProps {
|
||||||
data: CategoryBreakdownItem[];
|
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 { 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) {
|
if (data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -30,39 +54,84 @@ export default function CategoryBarChart({ data }: CategoryBarChartProps) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
<ResponsiveContainer width="100%" height={Math.max(400, data.length * 40)}>
|
{hiddenCategories.size > 0 && (
|
||||||
<BarChart data={data} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
<XAxis
|
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||||
type="number"
|
{Array.from(hiddenCategories).map((name) => (
|
||||||
tickFormatter={(v) => cadFormatter(v)}
|
<button
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
key={name}
|
||||||
stroke="var(--border)"
|
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"
|
||||||
<YAxis
|
>
|
||||||
type="category"
|
<Eye size={12} />
|
||||||
dataKey="category_name"
|
{name}
|
||||||
width={120}
|
</button>
|
||||||
tick={{ fill: "var(--muted-foreground)", fontSize: 12 }}
|
))}
|
||||||
stroke="var(--border)"
|
<button
|
||||||
/>
|
onClick={onShowAll}
|
||||||
<Tooltip
|
className="text-xs text-[var(--primary)] hover:underline"
|
||||||
formatter={(value: number | undefined) => cadFormatter(value ?? 0)}
|
>
|
||||||
contentStyle={{
|
{t("charts.showAll")}
|
||||||
backgroundColor: "var(--card)",
|
</button>
|
||||||
border: "1px solid var(--border)",
|
</div>
|
||||||
borderRadius: "8px",
|
)}
|
||||||
color: "var(--foreground)",
|
|
||||||
}}
|
<div onContextMenu={handleContextMenu}>
|
||||||
labelStyle={{ color: "var(--foreground)" }}
|
<ResponsiveContainer width="100%" height={Math.max(400, visibleData.length * 40)}>
|
||||||
itemStyle={{ color: "var(--foreground)" }}
|
<BarChart data={visibleData} layout="vertical" margin={{ top: 10, right: 30, left: 10, bottom: 0 }}>
|
||||||
/>
|
<ChartPatternDefs
|
||||||
<Bar dataKey="total" name={t("dashboard.expenses")} radius={[0, 4, 4, 0]}>
|
prefix="cat-bar"
|
||||||
{data.map((item, index) => (
|
categories={visibleData.map((item, index) => ({ color: item.category_color, index }))}
|
||||||
<Cell key={index} fill={item.category_color} />
|
/>
|
||||||
))}
|
<XAxis
|
||||||
</Bar>
|
type="number"
|
||||||
</BarChart>
|
tickFormatter={(v) => cadFormatter(v)}
|
||||||
</ResponsiveContainer>
|
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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useRef, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
BarChart,
|
BarChart,
|
||||||
|
|
@ -9,7 +10,10 @@ import {
|
||||||
Legend,
|
Legend,
|
||||||
CartesianGrid,
|
CartesianGrid,
|
||||||
} from "recharts";
|
} 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) =>
|
const cadFormatter = (value: number) =>
|
||||||
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
new Intl.NumberFormat("en-CA", { style: "currency", currency: "CAD", maximumFractionDigits: 0 }).format(value);
|
||||||
|
|
@ -22,10 +26,35 @@ function formatMonth(month: string): string {
|
||||||
|
|
||||||
interface CategoryOverTimeChartProps {
|
interface CategoryOverTimeChartProps {
|
||||||
data: CategoryOverTimeData;
|
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 { 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) {
|
if (data.data.length === 0) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -37,44 +66,95 @@ export default function CategoryOverTimeChart({ data }: CategoryOverTimeChartPro
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
<div className="bg-[var(--card)] rounded-xl p-6 border border-[var(--border)]">
|
||||||
<ResponsiveContainer width="100%" height={400}>
|
{hiddenCategories.size > 0 && (
|
||||||
<BarChart data={data.data} margin={{ top: 10, right: 10, left: 10, bottom: 0 }}>
|
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
<span className="text-xs text-[var(--muted-foreground)]">{t("charts.hiddenCategories")}:</span>
|
||||||
<XAxis
|
{Array.from(hiddenCategories).map((name) => (
|
||||||
dataKey="month"
|
<button
|
||||||
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
|
|
||||||
key={name}
|
key={name}
|
||||||
dataKey={name}
|
onClick={() => onToggleHidden(name)}
|
||||||
stackId="stack"
|
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"
|
||||||
fill={data.colors[name]}
|
>
|
||||||
/>
|
<Eye size={12} />
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
))}
|
))}
|
||||||
</BarChart>
|
<button
|
||||||
</ResponsiveContainer>
|
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>
|
</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]);
|
}, [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 () => {
|
const parsePreview = useCallback(async () => {
|
||||||
if (state.selectedFiles.length === 0) return;
|
if (state.selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
|
@ -425,220 +520,134 @@ export function useImportWizard() {
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = state.sourceConfig;
|
const result = await parseFilesInternal();
|
||||||
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",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_PARSED_PREVIEW",
|
type: "SET_PARSED_PREVIEW",
|
||||||
payload: { rows: allRows, headers },
|
payload: result,
|
||||||
});
|
});
|
||||||
dispatch({ type: "SET_STEP", payload: "file-preview" });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
payload: e instanceof Error ? e.message : String(e),
|
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 () => {
|
const checkDuplicates = useCallback(async () => {
|
||||||
dispatch({ type: "SET_LOADING", payload: true });
|
dispatch({ type: "SET_LOADING", payload: true });
|
||||||
dispatch({ type: "SET_ERROR", payload: null });
|
dispatch({ type: "SET_ERROR", payload: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Save/update source config in DB
|
await checkDuplicatesInternal(state.parsedPreview);
|
||||||
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" });
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_ERROR",
|
type: "SET_ERROR",
|
||||||
payload: e instanceof Error ? e.message : String(e),
|
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 () => {
|
const executeImport = useCallback(async () => {
|
||||||
if (!state.duplicateResult) return;
|
if (!state.duplicateResult) return;
|
||||||
|
|
@ -846,6 +855,7 @@ export function useImportWizard() {
|
||||||
selectAllFiles,
|
selectAllFiles,
|
||||||
parsePreview,
|
parsePreview,
|
||||||
checkDuplicates,
|
checkDuplicates,
|
||||||
|
parseAndCheckDuplicates,
|
||||||
executeImport,
|
executeImport,
|
||||||
goToStep,
|
goToStep,
|
||||||
reset,
|
reset,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ const initialState: ReportsState = {
|
||||||
period: "6months",
|
period: "6months",
|
||||||
monthlyTrends: [],
|
monthlyTrends: [],
|
||||||
categorySpending: [],
|
categorySpending: [],
|
||||||
categoryOverTime: { categories: [], data: [], colors: {} },
|
categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} },
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
error: null,
|
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": {
|
"common": {
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"cancel": "Cancel",
|
"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": {
|
"common": {
|
||||||
"save": "Enregistrer",
|
"save": "Enregistrer",
|
||||||
"cancel": "Annuler",
|
"cancel": "Annuler",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
import { Wallet, TrendingUp, TrendingDown } from "lucide-react";
|
||||||
import { useDashboard } from "../hooks/useDashboard";
|
import { useDashboard } from "../hooks/useDashboard";
|
||||||
|
|
@ -5,14 +6,52 @@ import { PageHelp } from "../components/shared/PageHelp";
|
||||||
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
import PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
import CategoryPieChart from "../components/dashboard/CategoryPieChart";
|
||||||
import RecentTransactionsList from "../components/dashboard/RecentTransactionsList";
|
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" });
|
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() {
|
export default function DashboardPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setPeriod } = useDashboard();
|
const { state, setPeriod } = useDashboard();
|
||||||
const { summary, categoryBreakdown, recentTransactions, period, isLoading } = state;
|
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 balance = summary.totalAmount;
|
||||||
const balanceColor =
|
const balanceColor =
|
||||||
balance > 0
|
balance > 0
|
||||||
|
|
@ -42,6 +81,8 @@ export default function DashboardPage() {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const { dateFrom, dateTo } = computeDateRange(period);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<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">
|
<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>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<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} />
|
<RecentTransactionsList transactions={recentTransactions} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{detailModal && (
|
||||||
|
<TransactionDetailModal
|
||||||
|
categoryId={detailModal.category_id}
|
||||||
|
categoryName={detailModal.category_name}
|
||||||
|
categoryColor={detailModal.category_color}
|
||||||
|
dateFrom={dateFrom}
|
||||||
|
dateTo={dateTo}
|
||||||
|
onClose={() => setDetailModal(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,17 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useImportWizard } from "../hooks/useImportWizard";
|
import { useImportWizard } from "../hooks/useImportWizard";
|
||||||
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
import ImportFolderConfig from "../components/import/ImportFolderConfig";
|
||||||
import SourceList from "../components/import/SourceList";
|
import SourceList from "../components/import/SourceList";
|
||||||
import SourceConfigPanel from "../components/import/SourceConfigPanel";
|
import SourceConfigPanel from "../components/import/SourceConfigPanel";
|
||||||
import FilePreviewTable from "../components/import/FilePreviewTable";
|
|
||||||
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
|
import DuplicateCheckPanel from "../components/import/DuplicateCheckPanel";
|
||||||
import ImportConfirmation from "../components/import/ImportConfirmation";
|
import ImportConfirmation from "../components/import/ImportConfirmation";
|
||||||
import ImportProgress from "../components/import/ImportProgress";
|
import ImportProgress from "../components/import/ImportProgress";
|
||||||
import ImportReportPanel from "../components/import/ImportReportPanel";
|
import ImportReportPanel from "../components/import/ImportReportPanel";
|
||||||
import WizardNavigation from "../components/import/WizardNavigation";
|
import WizardNavigation from "../components/import/WizardNavigation";
|
||||||
import ImportHistoryPanel from "../components/import/ImportHistoryPanel";
|
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";
|
import { PageHelp } from "../components/shared/PageHelp";
|
||||||
|
|
||||||
export default function ImportPage() {
|
export default function ImportPage() {
|
||||||
|
|
@ -24,7 +25,7 @@ export default function ImportPage() {
|
||||||
toggleFile,
|
toggleFile,
|
||||||
selectAllFiles,
|
selectAllFiles,
|
||||||
parsePreview,
|
parsePreview,
|
||||||
checkDuplicates,
|
parseAndCheckDuplicates,
|
||||||
executeImport,
|
executeImport,
|
||||||
goToStep,
|
goToStep,
|
||||||
reset,
|
reset,
|
||||||
|
|
@ -33,6 +34,15 @@ export default function ImportPage() {
|
||||||
setSkipAllDuplicates,
|
setSkipAllDuplicates,
|
||||||
} = useImportWizard();
|
} = 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="relative flex items-center gap-3 mb-6">
|
<div className="relative flex items-center gap-3 mb-6">
|
||||||
|
|
@ -84,39 +94,41 @@ export default function ImportPage() {
|
||||||
onAutoDetect={autoDetectConfig}
|
onAutoDetect={autoDetectConfig}
|
||||||
isLoading={state.isLoading}
|
isLoading={state.isLoading}
|
||||||
/>
|
/>
|
||||||
<WizardNavigation
|
<div className="flex items-center justify-between pt-6 border-t border-[var(--border)]">
|
||||||
onBack={() => goToStep("source-list")}
|
<div>
|
||||||
onNext={parsePreview}
|
<button
|
||||||
onCancel={reset}
|
onClick={reset}
|
||||||
nextLabel={t("import.wizard.preview")}
|
className="flex items-center gap-1 px-4 py-2 text-sm text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||||
nextDisabled={
|
>
|
||||||
state.selectedFiles.length === 0 || !state.sourceConfig.name
|
<X size={16} />
|
||||||
}
|
{t("common.cancel")}
|
||||||
/>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex items-center gap-3">
|
||||||
|
<button
|
||||||
{state.step === "file-preview" && (
|
onClick={() => goToStep("source-list")}
|
||||||
<div className="space-y-6">
|
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"
|
||||||
<FilePreviewTable
|
>
|
||||||
rows={state.parsedPreview.slice(0, 20)}
|
<ChevronLeft size={16} />
|
||||||
/>
|
{t("import.wizard.back")}
|
||||||
{state.parsedPreview.length > 20 && (
|
</button>
|
||||||
<p className="text-sm text-[var(--muted-foreground)] text-center">
|
<button
|
||||||
{t("import.preview.moreRows", {
|
onClick={handlePreview}
|
||||||
count: state.parsedPreview.length - 20,
|
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"
|
||||||
</p>
|
>
|
||||||
)}
|
<Eye size={16} />
|
||||||
<WizardNavigation
|
{t("import.wizard.preview")}
|
||||||
onBack={() => goToStep("source-config")}
|
</button>
|
||||||
onNext={checkDuplicates}
|
<button
|
||||||
onCancel={reset}
|
onClick={parseAndCheckDuplicates}
|
||||||
nextLabel={t("import.wizard.checkDuplicates")}
|
disabled={nextDisabled || state.isLoading}
|
||||||
nextDisabled={
|
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"
|
||||||
state.parsedPreview.filter((r) => r.parsed).length === 0
|
>
|
||||||
}
|
{t("import.wizard.checkDuplicates")}
|
||||||
/>
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
@ -130,7 +142,7 @@ export default function ImportPage() {
|
||||||
onIncludeAll={() => setSkipAllDuplicates(false)}
|
onIncludeAll={() => setSkipAllDuplicates(false)}
|
||||||
/>
|
/>
|
||||||
<WizardNavigation
|
<WizardNavigation
|
||||||
onBack={() => goToStep("file-preview")}
|
onBack={() => goToStep("source-config")}
|
||||||
onNext={() => goToStep("confirm")}
|
onNext={() => goToStep("confirm")}
|
||||||
onCancel={reset}
|
onCancel={reset}
|
||||||
nextLabel={t("import.wizard.confirm")}
|
nextLabel={t("import.wizard.confirm")}
|
||||||
|
|
@ -168,6 +180,15 @@ export default function ImportPage() {
|
||||||
{state.step === "report" && state.importReport && (
|
{state.step === "report" && state.importReport && (
|
||||||
<ImportReportPanel report={state.importReport} onDone={reset} />
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,58 @@
|
||||||
|
import { useState, useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useReports } from "../hooks/useReports";
|
import { useReports } from "../hooks/useReports";
|
||||||
import { PageHelp } from "../components/shared/PageHelp";
|
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 PeriodSelector from "../components/dashboard/PeriodSelector";
|
||||||
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
import MonthlyTrendsChart from "../components/reports/MonthlyTrendsChart";
|
||||||
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
import CategoryBarChart from "../components/reports/CategoryBarChart";
|
||||||
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
import CategoryOverTimeChart from "../components/reports/CategoryOverTimeChart";
|
||||||
|
import TransactionDetailModal from "../components/shared/TransactionDetailModal";
|
||||||
|
|
||||||
const TABS: ReportTab[] = ["trends", "byCategory", "overTime"];
|
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() {
|
export default function ReportsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { state, setTab, setPeriod } = useReports();
|
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 (
|
return (
|
||||||
<div className={state.isLoading ? "opacity-50 pointer-events-none" : ""}>
|
<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">
|
<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 === "trends" && <MonthlyTrendsChart data={state.monthlyTrends} />}
|
||||||
{state.tab === "byCategory" && <CategoryBarChart data={state.categorySpending} />}
|
{state.tab === "byCategory" && (
|
||||||
{state.tab === "overTime" && <CategoryOverTimeChart data={state.categoryOverTime} />}
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import type {
|
||||||
DashboardSummary,
|
DashboardSummary,
|
||||||
CategoryBreakdownItem,
|
CategoryBreakdownItem,
|
||||||
RecentTransaction,
|
RecentTransaction,
|
||||||
|
TransactionRow,
|
||||||
} from "../shared/types";
|
} from "../shared/types";
|
||||||
|
|
||||||
export async function getDashboardSummary(
|
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(
|
export async function getRecentTransactions(
|
||||||
limit: number = 10
|
limit: number = 10
|
||||||
): Promise<RecentTransaction[]> {
|
): Promise<RecentTransaction[]> {
|
||||||
|
|
|
||||||
|
|
@ -85,8 +85,10 @@ export async function getCategoryOverTime(
|
||||||
|
|
||||||
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
const topCategoryIds = new Set(topCategories.map((c) => c.category_id));
|
||||||
const colors: Record<string, string> = {};
|
const colors: Record<string, string> = {};
|
||||||
|
const categoryIds: Record<string, number | null> = {};
|
||||||
for (const cat of topCategories) {
|
for (const cat of topCategories) {
|
||||||
colors[cat.category_name] = cat.category_color;
|
colors[cat.category_name] = cat.category_color;
|
||||||
|
categoryIds[cat.category_name] = cat.category_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get monthly breakdown for all categories
|
// Get monthly breakdown for all categories
|
||||||
|
|
@ -142,5 +144,6 @@ export async function getCategoryOverTime(
|
||||||
categories,
|
categories,
|
||||||
data: Array.from(monthMap.values()),
|
data: Array.from(monthMap.values()),
|
||||||
colors,
|
colors,
|
||||||
|
categoryIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,7 @@ export interface CategoryOverTimeData {
|
||||||
categories: string[];
|
categories: string[];
|
||||||
data: CategoryOverTimeItem[];
|
data: CategoryOverTimeItem[];
|
||||||
colors: Record<string, string>;
|
colors: Record<string, string>;
|
||||||
|
categoryIds: Record<string, number | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportWizardStep =
|
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
|
# Task: Import preview as popup + direct skip to duplicate check
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|
||||||
## Plan
|
## Plan
|
||||||
- [x] Fix `deactivateCategory`: promote children to root, only deactivate the parent itself
|
- [x] Create `FilePreviewModal.tsx` — portal modal wrapping `FilePreviewTable`
|
||||||
- [x] Add `getChildrenUsageCount` to block deletion when children have transactions
|
- [x] Update `useImportWizard.ts` — extract `parseFilesInternal` helper, make `parsePreview` not change step, add `parseAndCheckDuplicates` combined function
|
||||||
- [x] Add `reinitializeCategories` service function (re-runs seed data)
|
- [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] Add `reinitializeCategories` to hook
|
- [x] Verify TypeScript compiles
|
||||||
- [x] Add re-initialize button with confirmation on CategoriesPage
|
|
||||||
- [x] Add i18n keys (en + fr)
|
## Progress Notes
|
||||||
- [x] Update deleteConfirm/deleteBlocked messages to reflect new behavior
|
- Extracted parsing logic into `parseFilesInternal` helper to avoid state closure issues when combining parse + duplicate check
|
||||||
- [x] `npm run build` passes
|
- `checkDuplicatesInternal` takes parsed rows as parameter so `parseAndCheckDuplicates` can pass them directly
|
||||||
|
- No new i18n keys needed — reused existing ones
|
||||||
|
|
||||||
## Review
|
## Review
|
||||||
6 files changed. Orphan fix promotes children to root level instead of cascading deactivation.
|
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.
|
||||||
Re-initialize button resets all categories+keywords to seed state (with user confirmation).
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue