diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..61fd98a --- /dev/null +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 6d82d1c..07a7011 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.2.2", + "version": "0.2.3", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e8c575a..a9d2b20 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -3770,7 +3770,7 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simpl-result" -version = "0.1.0" +version = "0.2.3" dependencies = [ "encoding_rs", "serde", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ed03217..68d9972 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 0e037f1..8663ab8 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -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", diff --git a/src/components/dashboard/CategoryPieChart.tsx b/src/components/dashboard/CategoryPieChart.tsx index 5283c80..40cde0a 100644 --- a/src/components/dashboard/CategoryPieChart.tsx +++ b/src/components/dashboard/CategoryPieChart.tsx @@ -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; + 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(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 (

{t("dashboard.expensesByCategory")}

- - - 0 && ( +
+ {t("charts.hiddenCategories")}: + {Array.from(hiddenCategories).map((name) => ( + + ))} + + ); + })} +
+ + {contextMenu && ( + onToggleHidden(contextMenu.item.category_name)} + onViewDetails={() => onViewDetails(contextMenu.item)} + onClose={() => setContextMenu(null)} + /> + )}
); } diff --git a/src/components/import/FilePreviewModal.tsx b/src/components/import/FilePreviewModal.tsx new file mode 100644 index 0000000..07057de --- /dev/null +++ b/src/components/import/FilePreviewModal.tsx @@ -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( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Header */} +
+

{t("import.preview.title")}

+ +
+ + {/* Body */} +
+ + {totalCount > rows.length && ( +

+ {t("import.preview.moreRows", { + count: totalCount - rows.length, + })} +

+ )} +
+
+
, + document.body + ); +} diff --git a/src/components/reports/CategoryBarChart.tsx b/src/components/reports/CategoryBarChart.tsx index 00e8a85..c319299 100644 --- a/src/components/reports/CategoryBarChart.tsx +++ b/src/components/reports/CategoryBarChart.tsx @@ -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; + 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(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 (
- - - cadFormatter(v)} - tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} - stroke="var(--border)" - /> - - 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)" }} - /> - - {data.map((item, index) => ( - - ))} - - - + {hiddenCategories.size > 0 && ( +
+ {t("charts.hiddenCategories")}: + {Array.from(hiddenCategories).map((name) => ( + + ))} + +
+ )} + +
+ + + ({ color: item.category_color, index }))} + /> + cadFormatter(v)} + tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} + stroke="var(--border)" + /> + + 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)" }} + /> + + {visibleData.map((item, index) => ( + { hoveredRef.current = item; }} + onMouseLeave={() => { hoveredRef.current = null; }} + cursor="context-menu" + /> + ))} + + + +
+ + {contextMenu && ( + onToggleHidden(contextMenu.item.category_name)} + onViewDetails={() => onViewDetails(contextMenu.item)} + onClose={() => setContextMenu(null)} + /> + )}
); } diff --git a/src/components/reports/CategoryOverTimeChart.tsx b/src/components/reports/CategoryOverTimeChart.tsx index 7793b90..8e4d728 100644 --- a/src/components/reports/CategoryOverTimeChart.tsx +++ b/src/components/reports/CategoryOverTimeChart.tsx @@ -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; + 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(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 (
- - - - - cadFormatter(v)} - tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} - stroke="var(--border)" - width={80} - /> - 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)" }} - /> - - {data.categories.map((name) => ( - 0 && ( +
+ {t("charts.hiddenCategories")}: + {Array.from(hiddenCategories).map((name) => ( + ))} - - + +
+ )} + +
+ + + ({ color: c.color, index: c.index }))} + /> + + + cadFormatter(v)} + tick={{ fill: "var(--muted-foreground)", fontSize: 12 }} + stroke="var(--border)" + width={80} + /> + 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)" }} + /> + + {categoryEntries.map((c) => ( + { hoveredRef.current = c.name; }} + onMouseLeave={() => { hoveredRef.current = null; }} + cursor="context-menu" + /> + ))} + + +
+ + {contextMenu && ( + 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)} + /> + )}
); } diff --git a/src/components/shared/ChartContextMenu.tsx b/src/components/shared/ChartContextMenu.tsx new file mode 100644 index 0000000..aa74f13 --- /dev/null +++ b/src/components/shared/ChartContextMenu.tsx @@ -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(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 ( +
+
+ {categoryName} +
+ + +
+ ); +} diff --git a/src/components/shared/TransactionDetailModal.tsx b/src/components/shared/TransactionDetailModal.tsx new file mode 100644 index 0000000..a43dfa4 --- /dev/null +++ b/src/components/shared/TransactionDetailModal.tsx @@ -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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Header */} +
+
+ +

{categoryName}

+ + ({rows.length} {t("charts.transactions")}) + +
+ +
+ + {/* Body */} +
+ {isLoading && ( +
+ +
+ )} + + {error && ( +
{error}
+ )} + + {!isLoading && !error && rows.length === 0 && ( +
+ {t("dashboard.noData")} +
+ )} + + {!isLoading && !error && rows.length > 0 && ( + + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + + + + + + + +
{t("transactions.date")}{t("transactions.description")}{t("transactions.amount")}
{row.date}{row.description}= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`}> + {cadFormatter.format(row.amount)} +
{t("charts.total")}= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]" + }`}> + {cadFormatter.format(total)} +
+ )} +
+
+
, + document.body + ); +} diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index bd80f10..71dc499 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -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("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("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("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("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(); - const batchDuplicateIndices = new Set(); - - 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, diff --git a/src/hooks/useReports.ts b/src/hooks/useReports.ts index 06073c1..383c453 100644 --- a/src/hooks/useReports.ts +++ b/src/hooks/useReports.ts @@ -33,7 +33,7 @@ const initialState: ReportsState = { period: "6months", monthlyTrends: [], categorySpending: [], - categoryOverTime: { categories: [], data: [], colors: {} }, + categoryOverTime: { categories: [], data: [], colors: {}, categoryIds: {} }, isLoading: false, error: null, }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 59f7bc6..045de3a 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -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", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index b17da45..d0a2466 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -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", diff --git a/src/pages/DashboardPage.tsx b/src/pages/DashboardPage.tsx index 2004220..56bdefe 100644 --- a/src/pages/DashboardPage.tsx +++ b/src/pages/DashboardPage.tsx @@ -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>(new Set()); + const [detailModal, setDetailModal] = useState(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 (
@@ -70,9 +111,26 @@ export default function DashboardPage() {
- +
+ + {detailModal && ( + setDetailModal(null)} + /> + )}
); } diff --git a/src/pages/ImportPage.tsx b/src/pages/ImportPage.tsx index 6e41341..24e4a21 100644 --- a/src/pages/ImportPage.tsx +++ b/src/pages/ImportPage.tsx @@ -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 (
@@ -84,39 +94,41 @@ export default function ImportPage() { onAutoDetect={autoDetectConfig} isLoading={state.isLoading} /> - goToStep("source-list")} - onNext={parsePreview} - onCancel={reset} - nextLabel={t("import.wizard.preview")} - nextDisabled={ - state.selectedFiles.length === 0 || !state.sourceConfig.name - } - /> -
- )} - - {state.step === "file-preview" && ( -
- - {state.parsedPreview.length > 20 && ( -

- {t("import.preview.moreRows", { - count: state.parsedPreview.length - 20, - })} -

- )} - goToStep("source-config")} - onNext={checkDuplicates} - onCancel={reset} - nextLabel={t("import.wizard.checkDuplicates")} - nextDisabled={ - state.parsedPreview.filter((r) => r.parsed).length === 0 - } - /> +
+
+ +
+
+ + + +
+
)} @@ -130,7 +142,7 @@ export default function ImportPage() { onIncludeAll={() => setSkipAllDuplicates(false)} /> 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 && ( )} + + {/* Preview modal */} + {showPreviewModal && state.parsedPreview.length > 0 && ( + setShowPreviewModal(false)} + /> + )}
); } diff --git a/src/pages/ReportsPage.tsx b/src/pages/ReportsPage.tsx index 3383ed4..6233ec8 100644 --- a/src/pages/ReportsPage.tsx +++ b/src/pages/ReportsPage.tsx @@ -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>(new Set()); + const [detailModal, setDetailModal] = useState(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 (
@@ -46,8 +86,35 @@ export default function ReportsPage() { )} {state.tab === "trends" && } - {state.tab === "byCategory" && } - {state.tab === "overTime" && } + {state.tab === "byCategory" && ( + + )} + {state.tab === "overTime" && ( + + )} + + {detailModal && ( + setDetailModal(null)} + /> + )}
); } diff --git a/src/services/dashboardService.ts b/src/services/dashboardService.ts index 0bbc863..61e97d8 100644 --- a/src/services/dashboardService.ts +++ b/src/services/dashboardService.ts @@ -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 { + 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( + `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 { diff --git a/src/services/reportService.ts b/src/services/reportService.ts index 1248d03..643cf64 100644 --- a/src/services/reportService.ts +++ b/src/services/reportService.ts @@ -85,8 +85,10 @@ export async function getCategoryOverTime( const topCategoryIds = new Set(topCategories.map((c) => c.category_id)); const colors: Record = {}; + const categoryIds: Record = {}; 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, }; } diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index 7c5138d..9d637f0 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -264,6 +264,7 @@ export interface CategoryOverTimeData { categories: string[]; data: CategoryOverTimeItem[]; colors: Record; + categoryIds: Record; } export type ImportWizardStep = diff --git a/src/utils/chartPatterns.tsx b/src/utils/chartPatterns.tsx new file mode 100644 index 0000000..59865c5 --- /dev/null +++ b/src/utils/chartPatterns.tsx @@ -0,0 +1,149 @@ +import React from "react"; + +// Pattern generators: each returns the SVG content for a 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°) + () => ( + + ), + // 2: Dots + () => ( + <> + + + + ), + // 3: Crosshatch + () => ( + <> + + + + ), + // 4: Horizontal lines + () => ( + + ), + // 5: Vertical lines + () => ( + + ), + // 6: Reverse diagonal (135°) + () => ( + + ), + // 7: Dense dots + () => ( + <> + + + + + + ), +]; + +/** + * 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 with 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 ( + + {categories.map(({ color, index }) => { + const pIdx = index % patternGenerators.length; + if (pIdx === 0) return null; // solid, no pattern needed + return ( + + + {patternGenerators[pIdx](color)} + + ); + })} + + ); +} + +/** + * 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 ( + + {pIdx !== 0 && ( + + + + {patternGenerators[pIdx](color)} + + + )} + + + ); +} diff --git a/tasks/todo.md b/tasks/todo.md index 7e04318..60be52d 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -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.