feat: show transaction splits on Adjustments page + fix CSV auto-detect
Some checks failed
Release / build (ubuntu-22.04) (push) Has been cancelled
Release / build (windows-latest) (push) Has been cancelled

Add a "Répartitions" section below manual adjustments listing all
split transactions. Clicking a split opens the existing modal to
view, edit, or delete it.

Fix CSV auto-detect failing on files with preamble lines (e.g.
Mastercard CSVs with metadata header). Three fixes:
- Delimiter detection uses mode of column counts instead of first-line
- Detect and skip preamble rows before header/data detection
- Exclude date-like columns from amount candidates and prefer columns
  with decimal values when picking the amount column

Bumps version to 0.3.4.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Le-King-Fu 2026-02-17 01:41:08 +00:00
parent 0b8d469699
commit b190df4eae
9 changed files with 208 additions and 20 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "simpl_result_scaffold", "name": "simpl_result_scaffold",
"private": true, "private": true,
"version": "0.3.3", "version": "0.3.4",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View file

@ -1,6 +1,6 @@
[package] [package]
name = "simpl-result" name = "simpl-result"
version = "0.3.3" version = "0.3.4"
description = "Personal finance management app" description = "Personal finance management app"
authors = ["you"] authors = ["you"]
edition = "2021" edition = "2021"

View file

@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Simpl Resultat", "productName": "Simpl Resultat",
"version": "0.3.3", "version": "0.3.4",
"identifier": "com.simpl.resultat", "identifier": "com.simpl.resultat",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View file

@ -880,8 +880,7 @@ export function useImportWizard() {
encoding: state.sourceConfig.encoding, encoding: state.sourceConfig.encoding,
}); });
const preprocessed = preprocessQuotedCSV(content); const result = runAutoDetect(content);
const result = runAutoDetect(preprocessed);
if (result) { if (result) {
const newConfig = { const newConfig = {

View file

@ -290,6 +290,7 @@
"selectAdjustment": "Select an adjustment", "selectAdjustment": "Select an adjustment",
"category": "Category", "category": "Category",
"noEntries": "No entries yet", "noEntries": "No entries yet",
"splitTransactions": "Transaction splits",
"help": { "help": {
"title": "How to use Adjustments", "title": "How to use Adjustments",
"tips": [ "tips": [

View file

@ -290,6 +290,7 @@
"selectAdjustment": "Sélectionnez un ajustement", "selectAdjustment": "Sélectionnez un ajustement",
"category": "Catégorie", "category": "Catégorie",
"noEntries": "Aucune entrée", "noEntries": "Aucune entrée",
"splitTransactions": "Répartitions de transactions",
"help": { "help": {
"title": "Comment utiliser les Ajustements", "title": "Comment utiliser les Ajustements",
"tips": [ "tips": [

View file

@ -1,12 +1,20 @@
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Plus } from "lucide-react"; import { Plus, Split } from "lucide-react";
import { PageHelp } from "../components/shared/PageHelp"; import { PageHelp } from "../components/shared/PageHelp";
import { useAdjustments } from "../hooks/useAdjustments"; import { useAdjustments } from "../hooks/useAdjustments";
import { getEntriesByAdjustmentId } from "../services/adjustmentService"; import { getEntriesByAdjustmentId } from "../services/adjustmentService";
import type { AdjustmentEntryWithCategory } 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 AdjustmentListPanel from "../components/adjustments/AdjustmentListPanel";
import AdjustmentDetailPanel from "../components/adjustments/AdjustmentDetailPanel"; import AdjustmentDetailPanel from "../components/adjustments/AdjustmentDetailPanel";
import SplitAdjustmentModal from "../components/transactions/SplitAdjustmentModal";
export default function AdjustmentsPage() { export default function AdjustmentsPage() {
const { t } = useTranslation(); const { t } = useTranslation();
@ -24,6 +32,9 @@ export default function AdjustmentsPage() {
new Map() new Map()
); );
const [splitTransactions, setSplitTransactions] = useState<TransactionRow[]>([]);
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
const loadAllEntries = useCallback(async () => { const loadAllEntries = useCallback(async () => {
const map = new Map<number, AdjustmentEntryWithCategory[]>(); const map = new Map<number, AdjustmentEntryWithCategory[]>();
for (const adj of state.adjustments) { for (const adj of state.adjustments) {
@ -43,6 +54,32 @@ export default function AdjustmentsPage() {
} }
}, [state.adjustments, loadAllEntries]); }, [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 = const selectedAdjustment =
state.selectedAdjustmentId !== null state.selectedAdjustmentId !== null
? state.adjustments.find((a) => a.id === state.selectedAdjustmentId) ?? null ? state.adjustments.find((a) => a.id === state.selectedAdjustmentId) ?? null
@ -81,6 +118,39 @@ export default function AdjustmentsPage() {
onSelect={selectAdjustment} onSelect={selectAdjustment}
entriesByAdjustment={entriesMap} entriesByAdjustment={entriesMap}
/> />
{splitTransactions.length > 0 && (
<>
<div className="flex items-center gap-2 mt-4 mb-2 px-1">
<Split size={14} className="text-[var(--foreground)]" />
<span className="text-xs font-semibold text-[var(--muted-foreground)] uppercase tracking-wide">
{t("adjustments.splitTransactions")}
</span>
</div>
<div className="flex flex-col gap-0.5">
{splitTransactions.map((tx) => (
<button
key={tx.id}
onClick={() => setSplitRow(tx)}
className="w-full flex flex-col gap-1 px-3 py-2.5 text-left rounded-lg transition-colors hover:bg-[var(--muted)]/50"
>
<span className="font-medium text-sm truncate">{tx.description}</span>
<div className="flex items-center justify-between gap-2">
<span className="text-xs text-[var(--muted-foreground)]">{tx.date}</span>
<span
className={`text-xs font-medium ${
tx.amount >= 0 ? "text-[var(--positive)]" : "text-[var(--negative)]"
}`}
>
{tx.amount >= 0 ? "+" : ""}
{tx.amount.toFixed(2)}
</span>
</div>
</button>
))}
</div>
</>
)}
</div> </div>
<AdjustmentDetailPanel <AdjustmentDetailPanel
selectedAdjustment={selectedAdjustment} selectedAdjustment={selectedAdjustment}
@ -97,6 +167,17 @@ export default function AdjustmentsPage() {
/> />
</div> </div>
)} )}
{splitRow && (
<SplitAdjustmentModal
transaction={splitRow}
categories={state.categories}
onLoadChildren={getSplitChildren}
onSave={handleSplitSave}
onDelete={handleSplitDelete}
onClose={() => setSplitRow(null)}
/>
)}
</div> </div>
); );
} }

View file

@ -272,6 +272,21 @@ export async function autoCategorizeTransactions(): Promise<number> {
return count; return count;
} }
export async function getSplitParentTransactions(): Promise<TransactionRow[]> {
const db = await getDb();
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,
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<SplitChild[]> { export async function getSplitChildren(parentId: number): Promise<SplitChild[]> {
const db = await getDb(); const db = await getDb();
return db.select<SplitChild[]>( return db.select<SplitChild[]>(

View file

@ -67,32 +67,80 @@ export function autoDetectConfig(rawContent: string): AutoDetectResult | null {
const data = parsed.data as string[][]; const data = parsed.data as string[][];
if (data.length < 2) return null; 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<number, number>();
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 // Step 2: Detect header
const hasHeader = detectHeader(data[0]); const hasHeader = detectHeader(effectiveData[0]);
const dataStartIdx = hasHeader ? 1 : 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; 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 // Step 3: Detect date column + format
const dateResult = detectDateColumn(sampleRows, colCount); const dateResult = detectDateColumn(sampleRows, colCount);
if (!dateResult) return null; if (!dateResult) return null;
// Step 3b: Find ALL date-like columns (to exclude from amount candidates)
const dateLikeCols = new Set<number>();
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 // Step 4: Detect numeric columns
const numericCols = detectNumericColumns(sampleRows, colCount); 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 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 // Step 6: Detect description column
const descriptionCol = detectDescriptionColumn( const descriptionCol = detectDescriptionColumn(
sampleRows, sampleRows,
colCount, colCount,
dateResult.column, dateResult.column,
new Set([...numericCols]) new Set([...numericCols, ...dateLikeCols])
); );
// Step 7: Determine amount mode // Step 7: Determine amount mode
@ -117,7 +165,7 @@ export function autoDetectConfig(rawContent: string): AutoDetectResult | null {
return { return {
delimiter, delimiter,
hasHeader, hasHeader,
skipLines: 0, skipLines,
dateFormat: dateResult.format, dateFormat: dateResult.format,
columnMapping: mapping, columnMapping: mapping,
amountMode: amountResult.mode, amountMode: amountResult.mode,
@ -135,12 +183,24 @@ function detectDelimiter(lines: string[]): string | null {
Papa.parse(line, { delimiter: delim }).data[0] as string[] Papa.parse(line, { delimiter: delim }).data[0] as string[]
).map((row) => row.length); ).map((row) => row.length);
// All lines should give consistent column count > 1 // Find the most frequent column count > 1 (tolerates preamble lines)
if (counts.length === 0 || counts[0] <= 1) continue; const countFreq = new Map<number, number>();
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]; let modeCount = 0;
const consistent = counts.filter((c) => c === firstCount).length; let modeFreq = 0;
const score = (consistent / counts.length) * firstCount; 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) { if (score > bestScore) {
bestScore = score; bestScore = score;
@ -409,8 +469,39 @@ function detectAmountMode(
} }
} }
// No complementary pair found — use first candidate as single amount // No complementary pair found — pick best single amount column
return detectSingleAmount(rows, amountCandidates[0]); 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( function detectSingleAmount(