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",
|
"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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 = {
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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": [
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]>(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue