diff --git a/package.json b/package.json index 1a00ccf..7f387fa 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "simpl_result_scaffold", "private": true, - "version": "0.3.3", + "version": "0.3.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 46a6c80..e0e6329 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "simpl-result" -version = "0.3.3" +version = "0.3.4" description = "Personal finance management app" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 47f218d..958dc93 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 Resultat", - "version": "0.3.3", + "version": "0.3.4", "identifier": "com.simpl.resultat", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/hooks/useImportWizard.ts b/src/hooks/useImportWizard.ts index e604e16..7bbbf2f 100644 --- a/src/hooks/useImportWizard.ts +++ b/src/hooks/useImportWizard.ts @@ -880,8 +880,7 @@ export function useImportWizard() { encoding: state.sourceConfig.encoding, }); - const preprocessed = preprocessQuotedCSV(content); - const result = runAutoDetect(preprocessed); + const result = runAutoDetect(content); if (result) { const newConfig = { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8c91ef2..e70238b 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -290,6 +290,7 @@ "selectAdjustment": "Select an adjustment", "category": "Category", "noEntries": "No entries yet", + "splitTransactions": "Transaction splits", "help": { "title": "How to use Adjustments", "tips": [ diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 86b9177..83b1663 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -290,6 +290,7 @@ "selectAdjustment": "Sélectionnez un ajustement", "category": "Catégorie", "noEntries": "Aucune entrée", + "splitTransactions": "Répartitions de transactions", "help": { "title": "Comment utiliser les Ajustements", "tips": [ diff --git a/src/pages/AdjustmentsPage.tsx b/src/pages/AdjustmentsPage.tsx index 15d1741..625a8d3 100644 --- a/src/pages/AdjustmentsPage.tsx +++ b/src/pages/AdjustmentsPage.tsx @@ -1,12 +1,20 @@ import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Plus } from "lucide-react"; +import { Plus, Split } from "lucide-react"; import { PageHelp } from "../components/shared/PageHelp"; import { useAdjustments } from "../hooks/useAdjustments"; import { getEntriesByAdjustmentId } from "../services/adjustmentService"; import type { AdjustmentEntryWithCategory } from "../services/adjustmentService"; +import { + getSplitParentTransactions, + getSplitChildren, + saveSplitAdjustment, + deleteSplitAdjustment, +} from "../services/transactionService"; +import type { TransactionRow } from "../shared/types"; import AdjustmentListPanel from "../components/adjustments/AdjustmentListPanel"; import AdjustmentDetailPanel from "../components/adjustments/AdjustmentDetailPanel"; +import SplitAdjustmentModal from "../components/transactions/SplitAdjustmentModal"; export default function AdjustmentsPage() { const { t } = useTranslation(); @@ -24,6 +32,9 @@ export default function AdjustmentsPage() { new Map() ); + const [splitTransactions, setSplitTransactions] = useState([]); + const [splitRow, setSplitRow] = useState(null); + const loadAllEntries = useCallback(async () => { const map = new Map(); for (const adj of state.adjustments) { @@ -43,6 +54,32 @@ export default function AdjustmentsPage() { } }, [state.adjustments, loadAllEntries]); + const loadSplitTransactions = useCallback(async () => { + try { + const rows = await getSplitParentTransactions(); + setSplitTransactions(rows); + } catch { + // ignore + } + }, []); + + useEffect(() => { + loadSplitTransactions(); + }, [loadSplitTransactions]); + + const handleSplitSave = async ( + parentId: number, + entries: Array<{ category_id: number; amount: number; description: string }> + ) => { + await saveSplitAdjustment(parentId, entries); + await loadSplitTransactions(); + }; + + const handleSplitDelete = async (parentId: number) => { + await deleteSplitAdjustment(parentId); + await loadSplitTransactions(); + }; + const selectedAdjustment = state.selectedAdjustmentId !== null ? state.adjustments.find((a) => a.id === state.selectedAdjustmentId) ?? null @@ -81,6 +118,39 @@ export default function AdjustmentsPage() { onSelect={selectAdjustment} entriesByAdjustment={entriesMap} /> + + {splitTransactions.length > 0 && ( + <> +
+ + + {t("adjustments.splitTransactions")} + +
+
+ {splitTransactions.map((tx) => ( + + ))} +
+ + )} )} + + {splitRow && ( + setSplitRow(null)} + /> + )} ); } diff --git a/src/services/transactionService.ts b/src/services/transactionService.ts index 930ae81..518830a 100644 --- a/src/services/transactionService.ts +++ b/src/services/transactionService.ts @@ -272,6 +272,21 @@ export async function autoCategorizeTransactions(): Promise { return count; } +export async function getSplitParentTransactions(): Promise { + const db = await getDb(); + 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, + t.is_split + FROM transactions t + LEFT JOIN categories c ON t.category_id = c.id + LEFT JOIN import_sources s ON t.source_id = s.id + WHERE t.is_split = 1 AND t.parent_transaction_id IS NULL + ORDER BY t.date DESC` + ); +} + export async function getSplitChildren(parentId: number): Promise { const db = await getDb(); return db.select( diff --git a/src/utils/csvAutoDetect.ts b/src/utils/csvAutoDetect.ts index c52761d..514e868 100644 --- a/src/utils/csvAutoDetect.ts +++ b/src/utils/csvAutoDetect.ts @@ -67,32 +67,80 @@ export function autoDetectConfig(rawContent: string): AutoDetectResult | null { const data = parsed.data as string[][]; if (data.length < 2) return null; + // Step 1b: Detect preamble lines to skip + // Find the expected column count (most frequent count > 1) + const colCountFreq = new Map(); + for (const row of data) { + const len = row.length; + if (len <= 1) continue; + colCountFreq.set(len, (colCountFreq.get(len) || 0) + 1); + } + let expectedColCount = 0; + let maxColFreq = 0; + for (const [count, freq] of colCountFreq) { + if (freq > maxColFreq) { + maxColFreq = freq; + expectedColCount = count; + } + } + + // Skip leading rows that don't match the expected column count + let skipLines = 0; + for (let i = 0; i < data.length; i++) { + if (data[i].length >= expectedColCount) break; + skipLines++; + } + + const effectiveData = data.slice(skipLines); + if (effectiveData.length < 2) return null; + // Step 2: Detect header - const hasHeader = detectHeader(data[0]); + const hasHeader = detectHeader(effectiveData[0]); const dataStartIdx = hasHeader ? 1 : 0; - const sampleRows = data.slice(dataStartIdx, dataStartIdx + 20); + const sampleRows = effectiveData.slice(dataStartIdx, dataStartIdx + 20); if (sampleRows.length === 0) return null; - const colCount = Math.max(...data.slice(0, 10).map((r) => r.length)); + const colCount = Math.max(...effectiveData.slice(0, 10).map((r) => r.length)); // Step 3: Detect date column + format const dateResult = detectDateColumn(sampleRows, colCount); if (!dateResult) return null; + // Step 3b: Find ALL date-like columns (to exclude from amount candidates) + const dateLikeCols = new Set(); + for (let col = 0; col < colCount; col++) { + for (const fmt of DATE_FORMATS) { + let success = 0; + let total = 0; + for (const row of sampleRows) { + const cell = row[col]?.trim(); + if (!cell) continue; + total++; + if (parseDate(cell, fmt)) success++; + } + if (total > 0 && success / total >= 0.8) { + dateLikeCols.add(col); + break; + } + } + } + // Step 4: Detect numeric columns const numericCols = detectNumericColumns(sampleRows, colCount); - // Step 5: Detect balance columns and exclude them + // Step 5: Detect balance columns and exclude them + date-like columns const balanceCols = detectBalanceColumns(sampleRows, numericCols); - const amountCandidates = numericCols.filter((c) => !balanceCols.has(c)); + const amountCandidates = numericCols.filter( + (c) => !balanceCols.has(c) && !dateLikeCols.has(c) + ); // Step 6: Detect description column const descriptionCol = detectDescriptionColumn( sampleRows, colCount, dateResult.column, - new Set([...numericCols]) + new Set([...numericCols, ...dateLikeCols]) ); // Step 7: Determine amount mode @@ -117,7 +165,7 @@ export function autoDetectConfig(rawContent: string): AutoDetectResult | null { return { delimiter, hasHeader, - skipLines: 0, + skipLines, dateFormat: dateResult.format, columnMapping: mapping, amountMode: amountResult.mode, @@ -135,12 +183,24 @@ function detectDelimiter(lines: string[]): string | null { Papa.parse(line, { delimiter: delim }).data[0] as string[] ).map((row) => row.length); - // All lines should give consistent column count > 1 - if (counts.length === 0 || counts[0] <= 1) continue; + // Find the most frequent column count > 1 (tolerates preamble lines) + const countFreq = new Map(); + for (const c of counts) { + if (c <= 1) continue; + countFreq.set(c, (countFreq.get(c) || 0) + 1); + } + if (countFreq.size === 0) continue; - const firstCount = counts[0]; - const consistent = counts.filter((c) => c === firstCount).length; - const score = (consistent / counts.length) * firstCount; + let modeCount = 0; + let modeFreq = 0; + for (const [count, freq] of countFreq) { + if (freq > modeFreq || (freq === modeFreq && count > modeCount)) { + modeFreq = freq; + modeCount = count; + } + } + + const score = (modeFreq / counts.length) * modeCount; if (score > bestScore) { bestScore = score; @@ -409,8 +469,39 @@ function detectAmountMode( } } - // No complementary pair found — use first candidate as single amount - return detectSingleAmount(rows, amountCandidates[0]); + // No complementary pair found — pick best single amount column + const bestCol = pickBestAmountColumn(rows, amountCandidates); + return detectSingleAmount(rows, bestCol); +} + +/** Pick the best amount column: prefer columns with decimal values (cents). */ +function pickBestAmountColumn(rows: string[][], candidates: number[]): number { + let bestCol = candidates[0]; + let bestScore = -1; + + for (const col of candidates) { + let decimalCount = 0; + let nonEmpty = 0; + + for (const row of rows) { + const cell = row[col]?.trim(); + if (!cell) continue; + const val = parseFrenchAmount(cell); + if (isNaN(val)) continue; + nonEmpty++; + if (!Number.isInteger(val)) { + decimalCount++; + } + } + + const score = nonEmpty > 0 ? decimalCount / nonEmpty : 0; + if (score > bestScore) { + bestScore = score; + bestCol = col; + } + } + + return bestCol; } function detectSingleAmount(