diff --git a/package.json b/package.json index 179ab0e..3e9be10 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.1.0", + "version": "0.2.1", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 59a6be6..e8c575a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -53,6 +53,15 @@ version = "1.0.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + [[package]] name = "async-broadcast" version = "0.7.2" @@ -703,6 +712,17 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", +] + [[package]] name = "derive_more" version = "0.99.20" @@ -992,6 +1012,17 @@ dependencies = [ "rustc_version", ] +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -1629,6 +1660,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -2139,6 +2186,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "minisign-verify" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e856fdd13623a2f5f2f54676a4ee49502a96a80ef4a62bcedd23d52427c44d43" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2458,6 +2511,18 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-osa-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0" +dependencies = [ + "bitflags 2.10.0", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-quartz-core" version = "0.3.2" @@ -2527,6 +2592,12 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "option-ext" version = "0.2.0" @@ -2543,6 +2614,20 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "osakit" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b" +dependencies = [ + "objc2", + "objc2-foundation", + "objc2-osa-kit", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pango" version = "0.18.3" @@ -3143,15 +3228,20 @@ dependencies = [ "http-body", "http-body-util", "hyper", + "hyper-rustls", "hyper-util", "js-sys", "log", "percent-encoding", "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", "serde", "serde_json", "sync_wrapper", "tokio", + "tokio-rustls", "tokio-util", "tower", "tower-http", @@ -3187,6 +3277,20 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.10" @@ -3229,6 +3333,79 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -3250,6 +3427,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -3307,6 +3493,29 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +dependencies = [ + "bitflags 2.10.0", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3571,7 +3780,9 @@ dependencies = [ "tauri-build", "tauri-plugin-dialog", "tauri-plugin-opener", + "tauri-plugin-process", "tauri-plugin-sql", + "tauri-plugin-updater", "walkdir", ] @@ -4046,6 +4257,17 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "tar" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "target-lexicon" version = "0.12.16" @@ -4245,6 +4467,16 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-process" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55511a7bf6cd70c8767b02c97bf8134fa434daf3926cfc1be0a0f94132d165a" +dependencies = [ + "tauri", + "tauri-plugin", +] + [[package]] name = "tauri-plugin-sql" version = "2.3.2" @@ -4265,6 +4497,39 @@ dependencies = [ "uuid", ] +[[package]] +name = "tauri-plugin-updater" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fe8e9bebd88fc222938ffdfbdcfa0307081423bd01e3252fc337d8bde81fc61" +dependencies = [ + "base64 0.22.1", + "dirs", + "flate2", + "futures-util", + "http", + "infer", + "log", + "minisign-verify", + "osakit", + "percent-encoding", + "reqwest", + "rustls", + "semver", + "serde", + "serde_json", + "tar", + "tauri", + "tauri-plugin", + "tempfile", + "thiserror 2.0.18", + "time", + "tokio", + "url", + "windows-sys 0.60.2", + "zip", +] + [[package]] name = "tauri-runtime" version = "2.10.0" @@ -4500,6 +4765,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-stream" version = "0.1.18" @@ -4822,6 +5097,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -5081,6 +5362,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "804f18a4ac2676ffb4e8b5b5fa9ae38af06df08162314f96a68d2a363e21a8ca" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -5330,6 +5620,15 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5724,6 +6023,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.1" @@ -5888,6 +6197,18 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "zip" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" +dependencies = [ + "arbitrary", + "crc32fast", + "indexmap 2.13.0", + "memchr", +] + [[package]] name = "zmij" version = "1.0.19" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 3d3990c..33e6058 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simpl-result" -version = "0.1.0" +version = "0.2.1" description = "Personal finance management app" authors = ["you"] edition = "2021" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2fbffd0..2b486c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -18,6 +18,12 @@ pub fn run() { sql: database::SEED_CATEGORIES, kind: MigrationKind::Up, }, + Migration { + version: 3, + description: "add has_header to import_sources", + sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;", + kind: MigrationKind::Up, + }, ]; tauri::Builder::default() diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 7a75bb6..a998377 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.1.2", + "version": "0.2.1", "identifier": "com.simpl.resultat", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/hooks/useCategories.ts b/src/hooks/useCategories.ts index efc8082..8251c07 100644 --- a/src/hooks/useCategories.ts +++ b/src/hooks/useCategories.ts @@ -10,10 +10,12 @@ import { updateCategory, deactivateCategory, getCategoryUsageCount, + getChildrenUsageCount, getKeywordsByCategoryId, createKeyword, updateKeyword, deactivateKeyword, + reinitializeCategories as reinitializeCategoriesSvc, } from "../services/categoryService"; interface CategoriesState { @@ -190,6 +192,11 @@ export function useCategories() { if (count > 0) { return { blocked: true, count }; } + // Also check children usage — they'll be promoted to root, not deleted + const childrenCount = await getChildrenUsageCount(id); + if (childrenCount > 0) { + return { blocked: true, count: childrenCount }; + } dispatch({ type: "SET_SAVING", payload: true }); try { await deactivateCategory(id); @@ -205,6 +212,19 @@ export function useCategories() { [loadCategories] ); + const reinitializeCategories = useCallback(async () => { + dispatch({ type: "SET_SAVING", payload: true }); + dispatch({ type: "SET_ERROR", payload: null }); + try { + await reinitializeCategoriesSvc(); + dispatch({ type: "SELECT_CATEGORY", payload: null }); + await loadCategories(); + dispatch({ type: "SET_SAVING", payload: false }); + } catch (e) { + dispatch({ type: "SET_ERROR", payload: e instanceof Error ? e.message : String(e) }); + } + }, [loadCategories]); + const loadKeywords = useCallback(async (categoryId: number) => { try { const kws = await getKeywordsByCategoryId(categoryId); @@ -268,5 +288,6 @@ export function useCategories() { addKeyword, editKeyword, removeKeyword, + reinitializeCategories, }; } diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index 33c5e3a..bd80f10 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -271,7 +271,7 @@ export function useImportWizard() { let activeDelimiter = defaultConfig.delimiter; let activeEncoding = "utf-8"; let activeSkipLines = 0; - const activeHasHeader = true; + let activeHasHeader = true; if (existing) { // Restore config from DB @@ -286,12 +286,13 @@ export function useImportWizard() { amountMode: mapping.debitAmount !== undefined ? "debit_credit" : "single", signConvention: "negative_expense", - hasHeader: true, + hasHeader: !!existing.has_header, }; dispatch({ type: "SET_SOURCE_CONFIG", payload: config }); activeDelimiter = existing.delimiter; activeEncoding = existing.encoding; activeSkipLines = existing.skip_lines; + activeHasHeader = !!existing.has_header; } else { // Auto-detect encoding for first file if (source.files.length > 0) { @@ -545,6 +546,7 @@ export function useImportWizard() { date_format: config.dateFormat, column_mapping: mappingJson, skip_lines: config.skipLines, + has_header: config.hasHeader, }); } else { sourceId = await createSource({ @@ -554,6 +556,7 @@ export function useImportWizard() { date_format: config.dateFormat, column_mapping: mappingJson, skip_lines: config.skipLines, + has_header: config.hasHeader, }); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c8d1f17..776f98c 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -230,8 +230,10 @@ "addCategory": "Add Category", "editCategory": "Edit Category", "deleteCategory": "Delete Category", - "deleteConfirm": "Are you sure you want to delete this category? Its children will also be deleted.", - "deleteBlocked": "Cannot delete: this category is used by {{count}} transaction(s).", + "deleteConfirm": "Are you sure you want to delete this category? Its children will be promoted to top-level.", + "deleteBlocked": "Cannot delete: this category or its children are used by {{count}} transaction(s).", + "reinitialize": "Re-initialize", + "reinitializeConfirm": "Reset all categories and keywords to their default values? Transaction categories will be unlinked. This cannot be undone.", "noParent": "No parent (top-level)", "sortOrder": "Sort Order", "selectCategory": "Select a category to view details", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index ca9bbda..46d2156 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -230,8 +230,10 @@ "addCategory": "Ajouter une catégorie", "editCategory": "Modifier la catégorie", "deleteCategory": "Supprimer la catégorie", - "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette catégorie ? Ses sous-catégories seront également supprimées.", - "deleteBlocked": "Impossible de supprimer : cette catégorie est utilisée par {{count}} transaction(s).", + "deleteConfirm": "Êtes-vous sûr de vouloir supprimer cette catégorie ? Ses sous-catégories seront promues au niveau supérieur.", + "deleteBlocked": "Impossible de supprimer : cette catégorie ou ses sous-catégories sont utilisées par {{count}} transaction(s).", + "reinitialize": "Réinitialiser", + "reinitializeConfirm": "Réinitialiser toutes les catégories et mots-clés à leurs valeurs par défaut ? Les catégories des transactions seront dissociées. Cette action est irréversible.", "noParent": "Aucun parent (niveau supérieur)", "sortOrder": "Ordre de tri", "selectCategory": "Sélectionnez une catégorie pour voir les détails", diff --git a/src/pages/CategoriesPage.tsx b/src/pages/CategoriesPage.tsx index fcf85ee..2f671df 100644 --- a/src/pages/CategoriesPage.tsx +++ b/src/pages/CategoriesPage.tsx @@ -1,5 +1,5 @@ import { useTranslation } from "react-i18next"; -import { Plus } from "lucide-react"; +import { Plus, RotateCcw } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; import { useCategories } from "../hooks/useCategories"; import CategoryTree from "../components/categories/CategoryTree"; @@ -18,8 +18,15 @@ export default function CategoriesPage() { addKeyword, editKeyword, removeKeyword, + reinitializeCategories, } = useCategories(); + const handleReinitialize = async () => { + if (confirm(t("categories.reinitializeConfirm"))) { + await reinitializeCategories(); + } + }; + const selectedCategory = state.selectedCategoryId !== null ? state.categories.find((c) => c.id === state.selectedCategoryId) ?? null @@ -32,13 +39,23 @@ export default function CategoriesPage() {

{t("categories.title")}

- +
+ + +
{state.error && ( diff --git a/src/services/categoryService.ts b/src/services/categoryService.ts index 27153dc..fa9b5bd 100644 --- a/src/services/categoryService.ts +++ b/src/services/categoryService.ts @@ -59,8 +59,14 @@ export async function updateCategory( export async function deactivateCategory(id: number): Promise { const db = await getDb(); + // Promote children to root level so they don't become orphans await db.execute( - `UPDATE categories SET is_active = 0 WHERE id = $1 OR parent_id = $1`, + `UPDATE categories SET parent_id = NULL WHERE parent_id = $1`, + [id] + ); + // Only deactivate the target category itself + await db.execute( + `UPDATE categories SET is_active = 0 WHERE id = $1`, [id] ); } @@ -74,6 +80,136 @@ export async function getCategoryUsageCount(id: number): Promise { return rows[0]?.cnt ?? 0; } +export async function getChildrenUsageCount(parentId: number): Promise { + const db = await getDb(); + const rows = await db.select>( + `SELECT COUNT(*) AS cnt FROM transactions WHERE category_id IN + (SELECT id FROM categories WHERE parent_id = $1 AND is_active = 1)`, + [parentId] + ); + return rows[0]?.cnt ?? 0; +} + +export async function reinitializeCategories(): Promise { + const db = await getDb(); + // Clear keywords, unlink transactions, delete all categories + await db.execute("DELETE FROM keywords"); + await db.execute("UPDATE transactions SET category_id = NULL"); + await db.execute("DELETE FROM categories"); + + // Re-seed parent categories + const parents = [ + [1, "Revenus", "income", 1], + [2, "Dépenses récurrentes", "expense", 2], + [3, "Dépenses ponctuelles", "expense", 3], + [4, "Maison", "expense", 4], + [5, "Placements", "transfer", 5], + [6, "Autres", "expense", 6], + ] as const; + for (const [id, name, type, sort] of parents) { + await db.execute( + "INSERT INTO categories (id, name, type, sort_order) VALUES ($1, $2, $3, $4)", + [id, name, type, sort] + ); + } + + // Re-seed child categories + const children: Array<[number, string, number, string, string, number]> = [ + [10, "Paie", 1, "income", "#22c55e", 1], + [11, "Autres revenus", 1, "income", "#4ade80", 2], + [20, "Loyer", 2, "expense", "#ef4444", 1], + [21, "Électricité", 2, "expense", "#f59e0b", 2], + [22, "Épicerie", 2, "expense", "#10b981", 3], + [23, "Dons", 2, "expense", "#ec4899", 4], + [24, "Restaurant", 2, "expense", "#f97316", 5], + [25, "Frais bancaires", 2, "expense", "#6b7280", 6], + [26, "Jeux, Films & Livres", 2, "expense", "#8b5cf6", 7], + [27, "Abonnements Musique", 2, "expense", "#06b6d4", 8], + [28, "Transport en commun", 2, "expense", "#3b82f6", 9], + [29, "Internet & Télécom", 2, "expense", "#6366f1", 10], + [30, "Animaux", 2, "expense", "#a855f7", 11], + [31, "Assurances", 2, "expense", "#14b8a6", 12], + [32, "Pharmacie", 2, "expense", "#f43f5e", 13], + [33, "Taxes municipales", 2, "expense", "#78716c", 14], + [40, "Voiture", 3, "expense", "#64748b", 1], + [41, "Amazon", 3, "expense", "#f59e0b", 2], + [42, "Électroniques", 3, "expense", "#3b82f6", 3], + [43, "Alcool", 3, "expense", "#7c3aed", 4], + [44, "Cadeaux", 3, "expense", "#ec4899", 5], + [45, "Vêtements", 3, "expense", "#d946ef", 6], + [46, "CPA", 3, "expense", "#0ea5e9", 7], + [47, "Voyage", 3, "expense", "#f97316", 8], + [48, "Sports & Plein air", 3, "expense", "#22c55e", 9], + [49, "Spectacles & sorties", 3, "expense", "#e11d48", 10], + [50, "Hypothèque", 4, "expense", "#dc2626", 1], + [51, "Achats maison", 4, "expense", "#ea580c", 2], + [52, "Entretien maison", 4, "expense", "#ca8a04", 3], + [53, "Électroménagers & Meubles", 4, "expense", "#0d9488", 4], + [54, "Outils", 4, "expense", "#b45309", 5], + [60, "Placements", 5, "transfer", "#2563eb", 1], + [61, "Transferts", 5, "transfer", "#7c3aed", 2], + [70, "Impôts", 6, "expense", "#dc2626", 1], + [71, "Paiement CC", 6, "transfer", "#6b7280", 2], + [72, "Retrait cash", 6, "expense", "#57534e", 3], + [73, "Projets", 6, "expense", "#0ea5e9", 4], + ]; + for (const [id, name, parentId, type, color, sort] of children) { + await db.execute( + "INSERT INTO categories (id, name, parent_id, type, color, sort_order) VALUES ($1, $2, $3, $4, $5, $6)", + [id, name, parentId, type, color, sort] + ); + } + + // Re-seed keywords + const keywords: Array<[string, number]> = [ + ["PAY/PAY", 10], + ["HYDRO-QUEBEC", 21], + ["METRO", 22], ["IGA", 22], ["MAXI", 22], ["SUPER C", 22], + ["BOUCHERIE LAFLECHE", 22], ["BOULANGERIE JARRY", 22], ["DOLLARAMA", 22], ["WALMART", 22], + ["OXFAM", 23], ["CENTRAIDE", 23], ["FPA", 23], + ["SUBWAY", 24], ["MCDONALD", 24], ["A&W", 24], ["DD/DOORDASH", 24], + ["DOORDASH", 24], ["SUSHI", 24], ["DOMINOS", 24], ["BELLE PROVINCE", 24], + ["PROGRAMME PERFORMANCE", 25], + ["STEAMGAMES", 26], ["PLAYSTATION", 26], ["PRIMEVIDEO", 26], ["NINTENDO", 26], + ["RENAUD-BRAY", 26], ["CINEMA DU PARC", 26], ["LEGO", 26], + ["SPOTIFY", 27], + ["STM", 28], ["GARE MONT-SAINT", 28], ["GARE SAINT-HUBERT", 28], + ["GARE CENTRALE", 28], ["REM", 28], + ["VIDEOTRON", 29], ["ORICOM", 29], + ["MONDOU", 30], + ["BELAIR", 31], ["PRYSM", 31], ["INS/ASS", 31], + ["JEAN COUTU", 32], ["FAMILIPRIX", 32], ["PHARMAPRIX", 32], + ["M-ST-HILAIRE TX", 33], ["CSS PATRIOT", 33], + ["SHELL", 40], ["ESSO", 40], ["ULTRAMAR", 40], ["PETRO-CANADA", 40], + ["SAAQ", 40], ["CREVIER", 40], + ["AMAZON", 41], ["AMZN", 41], + ["MICROSOFT", 42], ["ADDISON ELECTRONIQUE", 42], + ["SAQ", 43], ["SQDC", 43], + ["DANS UN JARDIN", 44], + ["UNIQLO", 45], ["WINNERS", 45], ["SIMONS", 45], + ["ORDRE DES COMPTABL", 46], + ["NORWEGIAN CRUISE", 47], ["AEROPORTS DE MONTREAL", 47], ["HILTON", 47], + ["BLOC SHOP", 48], ["SEPAQ", 48], ["LA CORDEE", 48], + ["MOUNTAIN EQUIPMENT", 48], ["PHYSIOACTIF", 48], ["DECATHLON", 48], + ["TICKETMASTER", 49], ["CLUB SODA", 49], ["LEPOINTDEVENTE", 49], + ["MTG/HYP", 50], + ["CANADIAN TIRE", 51], ["CANAC", 51], ["RONA", 51], + ["IKEA", 52], + ["TANGUAY", 53], ["BOUCLAIR", 53], + ["BMR", 54], ["HOME DEPOT", 54], ["PRINCESS AUTO", 54], + ["DYNAMIC FUND", 60], ["FIDELITY", 60], ["AGF", 60], + ["WS INVESTMENTS", 61], ["PEAK INVESTMENT", 61], + ["GOUV. QUEBEC", 70], + ["CLAUDE.AI", 73], ["NAME-CHEAP", 73], + ]; + for (const [kw, catId] of keywords) { + await db.execute( + "INSERT INTO keywords (keyword, category_id) VALUES ($1, $2)", + [kw, catId] + ); + } +} + export async function getKeywordsByCategoryId( categoryId: number ): Promise { diff --git a/src/services/importSourceService.ts b/src/services/importSourceService.ts index 16e1e84..a5f5353 100644 --- a/src/services/importSourceService.ts +++ b/src/services/importSourceService.ts @@ -33,8 +33,8 @@ export async function createSource( ): Promise { const db = await getDb(); const result = await db.execute( - `INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines) - VALUES ($1, $2, $3, $4, $5, $6, $7) + `INSERT INTO import_sources (name, description, date_format, delimiter, encoding, column_mapping, skip_lines, has_header) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT(name) DO UPDATE SET description = excluded.description, date_format = excluded.date_format, @@ -42,6 +42,7 @@ export async function createSource( encoding = excluded.encoding, column_mapping = excluded.column_mapping, skip_lines = excluded.skip_lines, + has_header = excluded.has_header, updated_at = CURRENT_TIMESTAMP`, [ source.name, @@ -51,6 +52,7 @@ export async function createSource( source.encoding, source.column_mapping, source.skip_lines, + source.has_header ? 1 : 0, ] ); // On conflict, lastInsertId may be 0 — look up the existing row @@ -96,6 +98,10 @@ export async function updateSource( fields.push(`skip_lines = $${paramIndex++}`); values.push(source.skip_lines); } + if (source.has_header !== undefined) { + fields.push(`has_header = $${paramIndex++}`); + values.push(source.has_header ? 1 : 0); + } if (fields.length === 0) return; diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index d7f3b44..7c5138d 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -7,6 +7,7 @@ export interface ImportSource { encoding: string; column_mapping: string; skip_lines: number; + has_header: boolean; created_at: string; updated_at: string; } diff --git a/tasks/lessons.md b/tasks/lessons.md index 87a7e45..3e59b34 100644 --- a/tasks/lessons.md +++ b/tasks/lessons.md @@ -35,3 +35,9 @@ **Pattern**: Identifier columns have very low cardinality relative to row count (often a single repeated value). Amount columns vary per transaction. **Rule**: When detecting "amount" columns in CSV auto-detect, exclude numeric columns with ≤1 distinct value. Also treat 0 as "empty" in sparse-complementary detection for debit/credit pairs. **Applied**: CSV auto-detection, any heuristic column type inference + +## 2026-02-12 - Config fields must be persisted to DB, not hardcoded on restore +**Mistake**: `hasHeader` was part of the SourceConfig in-memory object but was never stored in the `import_sources` DB table. When restoring config from DB, it was hardcoded to `true`, causing the first data row of headerless CSVs (Desjardins) to be treated as column headers. +**Pattern**: When adding a config field to an in-memory interface, you must also add the column to the DB schema and update all CRUD paths (create, update, restore). Hardcoding a default on restore silently loses user preferences. +**Rule**: For every field in a config interface: (1) verify it has a corresponding DB column, (2) verify it's included in INSERT/UPDATE queries, (3) verify it's restored from the DB row — never hardcoded. Use a grep for the field name across service, hook, and schema files. +**Applied**: Import source config, any settings/preferences that need to survive across sessions diff --git a/tasks/todo.md b/tasks/todo.md index 07d0a1b..7e04318 100644 --- a/tasks/todo.md +++ b/tasks/todo.md @@ -1,29 +1,21 @@ -# Task: Fix 3 Desjardins CSV Import Bugs +# Task: Fix orphan categories + add re-initialize button -## Root Cause Analysis - -### Bug 1: No-header columns show "0: Col 0" for every column -**Root cause:** `loadHeadersWithConfig` doesn't call `preprocessQuotedCSV()` before parsing. For Desjardins-style quoted CSVs, PapaParse sees each line as a single column. So `firstDataRow` has only 1 element → generates only `["Col 0"]`. - -### Bug 2: Going back from preview loses column mapping -**Root cause:** `parsePreview` only populates `headers` when `hasHeader: true`. When `hasHeader: false`, it leaves `headers = []`, overwriting `previewHeaders`. On source-config, `{headers.length > 0 && }` renders nothing. - -### Bug 3: Auto-detect amount picks account number column -**Root cause:** Constant numeric columns (account number, transit number) pass the "≥50% numeric" check. If the debit/credit columns have 0 instead of empty, `isSparseComplementary` fails. Falls back to first numeric candidate = account number. +## 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 -- [x] Bug 1: Add `preprocessQuotedCSV()` in `loadHeadersWithConfig` before PapaParse -- [x] Bug 2: In `parsePreview`, generate synthetic headers when `hasHeader: false` -- [x] Bug 3a: Exclude constant-value numeric columns from amount candidates -- [x] Bug 3b: Treat 0 values as empty in `isSparseComplementary` -- [x] Build verification -- [x] Update lessons.md - -## Progress Notes -- Bug 1: Added `preprocessQuotedCSV(preview)` call in `loadHeadersWithConfig` (useImportWizard.ts:341) -- Bug 2: Added else-if branch in `parsePreview` to generate `Col N` headers when hasHeader is false (useImportWizard.ts:450-453) -- Bug 3a: Added `distinctValues` tracking in `detectNumericColumns`, skip columns with ≤1 distinct value and >2 rows (csvAutoDetect.ts:227-236) -- Bug 3b: Changed `isSparseComplementary` to parse values and treat `0` as empty (csvAutoDetect.ts:452-455) +- [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 ## Review -Build passes. 2 files changed: useImportWizard.ts, csvAutoDetect.ts. All fixes are minimal and targeted. +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).