feat: show transaction splits on Adjustments page + fix CSV auto-detect
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:
parent
0b8d469699
commit
b190df4eae
9 changed files with 208 additions and 20 deletions
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "simpl_result_scaffold",
|
||||
"private": true,
|
||||
"version": "0.3.3",
|
||||
"version": "0.3.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -290,6 +290,7 @@
|
|||
"selectAdjustment": "Select an adjustment",
|
||||
"category": "Category",
|
||||
"noEntries": "No entries yet",
|
||||
"splitTransactions": "Transaction splits",
|
||||
"help": {
|
||||
"title": "How to use Adjustments",
|
||||
"tips": [
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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<TransactionRow[]>([]);
|
||||
const [splitRow, setSplitRow] = useState<TransactionRow | null>(null);
|
||||
|
||||
const loadAllEntries = useCallback(async () => {
|
||||
const map = new Map<number, AdjustmentEntryWithCategory[]>();
|
||||
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 && (
|
||||
<>
|
||||
<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>
|
||||
<AdjustmentDetailPanel
|
||||
selectedAdjustment={selectedAdjustment}
|
||||
|
|
@ -97,6 +167,17 @@ export default function AdjustmentsPage() {
|
|||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{splitRow && (
|
||||
<SplitAdjustmentModal
|
||||
transaction={splitRow}
|
||||
categories={state.categories}
|
||||
onLoadChildren={getSplitChildren}
|
||||
onSave={handleSplitSave}
|
||||
onDelete={handleSplitDelete}
|
||||
onClose={() => setSplitRow(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,6 +272,21 @@ export async function autoCategorizeTransactions(): Promise<number> {
|
|||
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[]> {
|
||||
const db = await getDb();
|
||||
return db.select<SplitChild[]>(
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
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<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
|
||||
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<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];
|
||||
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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue