feat: add import config templates, budget/category fixes (v0.2.5)
Add reusable import config templates so users can save and apply CSV parsing configurations across different import sources. Includes database table, service, hook integration, and template UI in the source config panel. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
720f52bad6
commit
ccdab1f06a
15 changed files with 380 additions and 27 deletions
|
|
@ -34,6 +34,7 @@ CREATE TABLE IF NOT EXISTS categories (
|
|||
icon TEXT,
|
||||
type TEXT NOT NULL DEFAULT 'expense', -- 'expense', 'income', 'transfer'
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_inputable INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES categories(id) ON DELETE SET NULL
|
||||
|
|
@ -136,6 +137,20 @@ CREATE TABLE IF NOT EXISTS budget_template_entries (
|
|||
UNIQUE(template_id, category_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_preferences (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL,
|
||||
|
|
|
|||
|
|
@ -24,6 +24,30 @@ pub fn run() {
|
|||
sql: "ALTER TABLE import_sources ADD COLUMN has_header INTEGER NOT NULL DEFAULT 1;",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 4,
|
||||
description: "add is_inputable to categories",
|
||||
sql: "ALTER TABLE categories ADD COLUMN is_inputable INTEGER NOT NULL DEFAULT 1;",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
Migration {
|
||||
version: 5,
|
||||
description: "create import_config_templates table",
|
||||
sql: "CREATE TABLE IF NOT EXISTS import_config_templates (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
delimiter TEXT NOT NULL DEFAULT ';',
|
||||
encoding TEXT NOT NULL DEFAULT 'utf-8',
|
||||
date_format TEXT NOT NULL DEFAULT 'DD/MM/YYYY',
|
||||
skip_lines INTEGER NOT NULL DEFAULT 0,
|
||||
has_header INTEGER NOT NULL DEFAULT 1,
|
||||
column_mapping TEXT NOT NULL,
|
||||
amount_mode TEXT NOT NULL DEFAULT 'single',
|
||||
sign_convention TEXT NOT NULL DEFAULT 'negative_expense',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);",
|
||||
kind: MigrationKind::Up,
|
||||
},
|
||||
];
|
||||
|
||||
tauri::Builder::default()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useState, useRef, useEffect, Fragment } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { SplitSquareHorizontal } from "lucide-react";
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import type { BudgetYearRow } from "../../shared/types";
|
||||
|
||||
const fmt = new Intl.NumberFormat("en-CA", {
|
||||
|
|
@ -25,8 +25,10 @@ interface BudgetTableProps {
|
|||
export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: BudgetTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const [editingCell, setEditingCell] = useState<{ categoryId: number; monthIdx: number } | null>(null);
|
||||
const [editingAnnual, setEditingAnnual] = useState<{ categoryId: number } | null>(null);
|
||||
const [editingValue, setEditingValue] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const annualInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingCell && inputRef.current) {
|
||||
|
|
@ -35,11 +37,25 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
}
|
||||
}, [editingCell]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editingAnnual && annualInputRef.current) {
|
||||
annualInputRef.current.focus();
|
||||
annualInputRef.current.select();
|
||||
}
|
||||
}, [editingAnnual]);
|
||||
|
||||
const handleStartEdit = (categoryId: number, monthIdx: number, currentValue: number) => {
|
||||
setEditingAnnual(null);
|
||||
setEditingCell({ categoryId, monthIdx });
|
||||
setEditingValue(currentValue === 0 ? "" : String(currentValue));
|
||||
};
|
||||
|
||||
const handleStartEditAnnual = (categoryId: number, currentValue: number) => {
|
||||
setEditingCell(null);
|
||||
setEditingAnnual({ categoryId });
|
||||
setEditingValue(currentValue === 0 ? "" : String(currentValue));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!editingCell) return;
|
||||
const amount = parseFloat(editingValue) || 0;
|
||||
|
|
@ -47,8 +63,16 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
setEditingCell(null);
|
||||
};
|
||||
|
||||
const handleSaveAnnual = () => {
|
||||
if (!editingAnnual) return;
|
||||
const amount = parseFloat(editingValue) || 0;
|
||||
onSplitEvenly(editingAnnual.categoryId, amount);
|
||||
setEditingAnnual(null);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditingCell(null);
|
||||
setEditingAnnual(null);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
|
|
@ -72,6 +96,11 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
}
|
||||
};
|
||||
|
||||
const handleAnnualKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") handleSaveAnnual();
|
||||
if (e.key === "Escape") handleCancel();
|
||||
};
|
||||
|
||||
// Group rows by type
|
||||
const grouped: Record<string, BudgetYearRow[]> = {};
|
||||
for (const row of rows) {
|
||||
|
|
@ -154,26 +183,41 @@ export default function BudgetTable({ rows, onUpdatePlanned, onSplitEvenly }: Bu
|
|||
<span className="truncate text-xs">{row.category_name}</span>
|
||||
</div>
|
||||
</td>
|
||||
{/* Annual total + split button */}
|
||||
{/* Annual total — editable */}
|
||||
<td className="py-2 px-2 text-right">
|
||||
{editingAnnual?.categoryId === row.category_id ? (
|
||||
<input
|
||||
ref={annualInputRef}
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={editingValue}
|
||||
onChange={(e) => setEditingValue(e.target.value)}
|
||||
onBlur={handleSaveAnnual}
|
||||
onKeyDown={handleAnnualKeyDown}
|
||||
className="w-full text-right bg-[var(--background)] border border-[var(--border)] rounded px-1 py-0.5 text-xs focus:outline-none focus:ring-1 focus:ring-[var(--primary)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<span className="font-medium text-xs">
|
||||
<button
|
||||
onClick={() => handleStartEditAnnual(row.category_id, row.annual)}
|
||||
className="font-medium text-xs hover:text-[var(--primary)] transition-colors cursor-text"
|
||||
>
|
||||
{row.annual === 0 ? (
|
||||
<span className="text-[var(--muted-foreground)]">—</span>
|
||||
) : (
|
||||
fmt.format(row.annual)
|
||||
)}
|
||||
</span>
|
||||
{row.annual > 0 && (
|
||||
<button
|
||||
onClick={() => onSplitEvenly(row.category_id, row.annual)}
|
||||
className="p-0.5 rounded hover:bg-[var(--muted)] text-[var(--muted-foreground)] hover:text-[var(--primary)] transition-colors"
|
||||
title={t("budget.splitEvenly")}
|
||||
>
|
||||
<SplitSquareHorizontal size={13} />
|
||||
</button>
|
||||
)}
|
||||
{(() => {
|
||||
const monthSum = row.months.reduce((s, v) => s + v, 0);
|
||||
return row.annual !== 0 && Math.abs(row.annual - monthSum) > 0.01 ? (
|
||||
<span title={t("budget.annualMismatch")} className="text-[var(--negative)]">
|
||||
<AlertTriangle size={13} />
|
||||
</span>
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
{/* 12 month cells */}
|
||||
{row.months.map((val, mIdx) => (
|
||||
|
|
|
|||
|
|
@ -119,6 +119,18 @@ export default function CategoryForm({
|
|||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_inputable"
|
||||
checked={form.is_inputable}
|
||||
onChange={(e) => setForm({ ...form, is_inputable: e.target.checked })}
|
||||
className="w-4 h-4 rounded border-[var(--border)] accent-[var(--primary)]"
|
||||
/>
|
||||
<label htmlFor="is_inputable" className="text-sm font-medium">{t("categories.isInputable")}</label>
|
||||
<span className="text-xs text-[var(--muted-foreground)]">{t("categories.isInputableHint")}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">{t("categories.sortOrder")}</label>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Wand2, Check } from "lucide-react";
|
||||
import { Wand2, Check, Save, X } from "lucide-react";
|
||||
import type {
|
||||
ScannedSource,
|
||||
ScannedFile,
|
||||
SourceConfig,
|
||||
AmountMode,
|
||||
ColumnMapping,
|
||||
ImportConfigTemplate,
|
||||
} from "../../shared/types";
|
||||
import ColumnMappingEditor from "./ColumnMappingEditor";
|
||||
|
||||
|
|
@ -15,10 +17,14 @@ interface SourceConfigPanelProps {
|
|||
selectedFiles: ScannedFile[];
|
||||
importedFileNames?: Set<string>;
|
||||
headers: string[];
|
||||
configTemplates: ImportConfigTemplate[];
|
||||
onConfigChange: (config: SourceConfig) => void;
|
||||
onFileToggle: (file: ScannedFile) => void;
|
||||
onSelectAllFiles: () => void;
|
||||
onAutoDetect: () => void;
|
||||
onSaveAsTemplate: (name: string) => void;
|
||||
onApplyTemplate: (id: number) => void;
|
||||
onDeleteTemplate: (id: number) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -28,13 +34,19 @@ export default function SourceConfigPanel({
|
|||
selectedFiles,
|
||||
importedFileNames,
|
||||
headers,
|
||||
configTemplates,
|
||||
onConfigChange,
|
||||
onFileToggle,
|
||||
onSelectAllFiles,
|
||||
onAutoDetect,
|
||||
onSaveAsTemplate,
|
||||
onApplyTemplate,
|
||||
onDeleteTemplate,
|
||||
isLoading,
|
||||
}: SourceConfigPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showSaveTemplate, setShowSaveTemplate] = useState(false);
|
||||
const [templateName, setTemplateName] = useState("");
|
||||
|
||||
const selectClass =
|
||||
"w-full px-3 py-2 text-sm rounded-lg border border-[var(--border)] bg-[var(--card)] text-[var(--foreground)] focus:outline-none focus:ring-2 focus:ring-[var(--primary)]";
|
||||
|
|
@ -60,6 +72,100 @@ export default function SourceConfigPanel({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Template row */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[200px]">
|
||||
<label className="text-sm text-[var(--muted-foreground)] whitespace-nowrap">
|
||||
{t("import.config.loadTemplate")}
|
||||
</label>
|
||||
<select
|
||||
value=""
|
||||
onChange={(e) => {
|
||||
if (e.target.value) onApplyTemplate(Number(e.target.value));
|
||||
}}
|
||||
className={selectClass + " flex-1"}
|
||||
>
|
||||
<option value="">
|
||||
{configTemplates.length === 0
|
||||
? t("import.config.noTemplates")
|
||||
: `— ${t("import.config.loadTemplate")} —`}
|
||||
</option>
|
||||
{configTemplates.map((tpl) => (
|
||||
<option key={tpl.id} value={tpl.id}>
|
||||
{tpl.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{configTemplates.length > 0 && (
|
||||
<div className="flex gap-1">
|
||||
{configTemplates.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
onClick={() => onDeleteTemplate(tpl.id)}
|
||||
title={`${t("import.config.deleteTemplate")}: ${tpl.name}`}
|
||||
className="p-1 text-[var(--muted-foreground)] hover:text-[var(--negative)] transition-colors"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSaveTemplate ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={templateName}
|
||||
onChange={(e) => setTemplateName(e.target.value)}
|
||||
placeholder={t("import.config.templateName")}
|
||||
className={inputClass + " w-48"}
|
||||
autoFocus
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && templateName.trim()) {
|
||||
onSaveAsTemplate(templateName.trim());
|
||||
setTemplateName("");
|
||||
setShowSaveTemplate(false);
|
||||
} else if (e.key === "Escape") {
|
||||
setShowSaveTemplate(false);
|
||||
setTemplateName("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (templateName.trim()) {
|
||||
onSaveAsTemplate(templateName.trim());
|
||||
setTemplateName("");
|
||||
setShowSaveTemplate(false);
|
||||
}
|
||||
}}
|
||||
disabled={!templateName.trim()}
|
||||
className="p-1.5 rounded-lg bg-[var(--positive)] text-white hover:opacity-90 disabled:opacity-50 transition-opacity"
|
||||
>
|
||||
<Check size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowSaveTemplate(false);
|
||||
setTemplateName("");
|
||||
}}
|
||||
className="p-1.5 rounded-lg text-[var(--muted-foreground)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowSaveTemplate(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-[var(--border)] text-[var(--foreground)] hover:bg-[var(--muted)] transition-colors"
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("import.config.saveAsTemplate")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Source name */}
|
||||
<div>
|
||||
<label className="block text-sm text-[var(--muted-foreground)] mb-1">
|
||||
|
|
|
|||
|
|
@ -91,7 +91,7 @@ function reducer(state: CategoriesState, action: CategoriesAction): CategoriesSt
|
|||
...state,
|
||||
isCreating: true,
|
||||
selectedCategoryId: null,
|
||||
editingCategory: { name: "", type: "expense", color: "#4A90A4", parent_id: null, sort_order: 0 },
|
||||
editingCategory: { name: "", type: "expense", color: "#4A90A4", parent_id: null, is_inputable: true, sort_order: 0 },
|
||||
keywords: [],
|
||||
};
|
||||
case "START_EDITING":
|
||||
|
|
@ -154,6 +154,7 @@ export function useCategories() {
|
|||
type: cat.type,
|
||||
color: cat.color ?? "#4A90A4",
|
||||
parent_id: cat.parent_id,
|
||||
is_inputable: cat.is_inputable,
|
||||
sort_order: cat.sort_order,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import type {
|
|||
DuplicateCheckResult,
|
||||
ImportReport,
|
||||
ImportSource,
|
||||
ImportConfigTemplate,
|
||||
ColumnMapping,
|
||||
} from "../shared/types";
|
||||
import {
|
||||
|
|
@ -33,6 +34,11 @@ import {
|
|||
findDuplicates,
|
||||
} from "../services/transactionService";
|
||||
import { categorizeBatch } from "../services/categorizationService";
|
||||
import {
|
||||
getAllTemplates,
|
||||
createTemplate,
|
||||
deleteTemplate as deleteTemplateService,
|
||||
} from "../services/importConfigTemplateService";
|
||||
import { parseDate } from "../utils/dateParser";
|
||||
import { parseFrenchAmount } from "../utils/amountParser";
|
||||
import {
|
||||
|
|
@ -58,6 +64,7 @@ interface WizardState {
|
|||
error: string | null;
|
||||
configuredSourceNames: Set<string>;
|
||||
importedFilesBySource: Map<string, Set<string>>;
|
||||
configTemplates: ImportConfigTemplate[];
|
||||
}
|
||||
|
||||
type WizardAction =
|
||||
|
|
@ -77,6 +84,7 @@ type WizardAction =
|
|||
| { type: "SET_IMPORT_REPORT"; payload: ImportReport }
|
||||
| { type: "SET_IMPORT_PROGRESS"; payload: { current: number; total: number; file: string } }
|
||||
| { type: "SET_CONFIGURED_SOURCES"; payload: { names: Set<string>; files: Map<string, Set<string>> } }
|
||||
| { type: "SET_CONFIG_TEMPLATES"; payload: ImportConfigTemplate[] }
|
||||
| { type: "RESET" };
|
||||
|
||||
const defaultConfig: SourceConfig = {
|
||||
|
|
@ -109,6 +117,7 @@ const initialState: WizardState = {
|
|||
error: null,
|
||||
configuredSourceNames: new Set(),
|
||||
importedFilesBySource: new Map(),
|
||||
configTemplates: [],
|
||||
};
|
||||
|
||||
function reducer(state: WizardState, action: WizardAction): WizardState {
|
||||
|
|
@ -171,6 +180,8 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
|||
configuredSourceNames: action.payload.names,
|
||||
importedFilesBySource: action.payload.files,
|
||||
};
|
||||
case "SET_CONFIG_TEMPLATES":
|
||||
return { ...state, configTemplates: action.payload };
|
||||
case "RESET":
|
||||
return {
|
||||
...initialState,
|
||||
|
|
@ -178,6 +189,7 @@ function reducer(state: WizardState, action: WizardAction): WizardState {
|
|||
scannedSources: state.scannedSources,
|
||||
configuredSourceNames: state.configuredSourceNames,
|
||||
importedFilesBySource: state.importedFilesBySource,
|
||||
configTemplates: state.configTemplates,
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
|
|
@ -216,6 +228,9 @@ export function useImportWizard() {
|
|||
}
|
||||
|
||||
dispatch({ type: "SET_CONFIGURED_SOURCES", payload: { names, files } });
|
||||
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, []);
|
||||
|
||||
const scanFolderInternal = useCallback(
|
||||
|
|
@ -858,6 +873,58 @@ export function useImportWizard() {
|
|||
}
|
||||
}, [state.selectedFiles, state.sourceConfig, loadHeadersWithConfig]);
|
||||
|
||||
const saveConfigAsTemplate = useCallback(async (name: string) => {
|
||||
const config = state.sourceConfig;
|
||||
await createTemplate({
|
||||
name,
|
||||
delimiter: config.delimiter,
|
||||
encoding: config.encoding,
|
||||
date_format: config.dateFormat,
|
||||
skip_lines: config.skipLines,
|
||||
has_header: config.hasHeader ? 1 : 0,
|
||||
column_mapping: JSON.stringify(config.columnMapping),
|
||||
amount_mode: config.amountMode,
|
||||
sign_convention: config.signConvention,
|
||||
});
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, [state.sourceConfig]);
|
||||
|
||||
const applyConfigTemplate = useCallback((templateId: number) => {
|
||||
const template = state.configTemplates.find((t) => t.id === templateId);
|
||||
if (!template) return;
|
||||
const mapping = JSON.parse(template.column_mapping) as ColumnMapping;
|
||||
const newConfig: SourceConfig = {
|
||||
name: state.sourceConfig.name,
|
||||
delimiter: template.delimiter,
|
||||
encoding: template.encoding,
|
||||
dateFormat: template.date_format,
|
||||
skipLines: template.skip_lines,
|
||||
columnMapping: mapping,
|
||||
amountMode: template.amount_mode,
|
||||
signConvention: template.sign_convention,
|
||||
hasHeader: !!template.has_header,
|
||||
};
|
||||
dispatch({ type: "SET_SOURCE_CONFIG", payload: newConfig });
|
||||
|
||||
// Reload headers with new config
|
||||
if (state.selectedFiles.length > 0) {
|
||||
loadHeadersWithConfig(
|
||||
state.selectedFiles[0].file_path,
|
||||
newConfig.delimiter,
|
||||
newConfig.encoding,
|
||||
newConfig.skipLines,
|
||||
newConfig.hasHeader
|
||||
);
|
||||
}
|
||||
}, [state.configTemplates, state.sourceConfig.name, state.selectedFiles, loadHeadersWithConfig]);
|
||||
|
||||
const deleteConfigTemplate = useCallback(async (id: number) => {
|
||||
await deleteTemplateService(id);
|
||||
const templates = await getAllTemplates();
|
||||
dispatch({ type: "SET_CONFIG_TEMPLATES", payload: templates });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
state,
|
||||
browseFolder,
|
||||
|
|
@ -873,6 +940,9 @@ export function useImportWizard() {
|
|||
goToStep,
|
||||
reset,
|
||||
autoDetectConfig,
|
||||
saveConfigAsTemplate,
|
||||
applyConfigTemplate,
|
||||
deleteConfigTemplate,
|
||||
toggleDuplicateRow: (index: number) =>
|
||||
dispatch({ type: "TOGGLE_DUPLICATE_ROW", payload: index }),
|
||||
setSkipAllDuplicates: (skipAll: boolean) =>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,13 @@
|
|||
"selectFiles": "Files to import",
|
||||
"selectAll": "Select all",
|
||||
"alreadyImported": "Imported",
|
||||
"autoDetect": "Auto-detect"
|
||||
"autoDetect": "Auto-detect",
|
||||
"saveAsTemplate": "Save as template",
|
||||
"loadTemplate": "Load template",
|
||||
"templateName": "Template name",
|
||||
"templateSaved": "Template saved",
|
||||
"deleteTemplate": "Delete template",
|
||||
"noTemplates": "No templates saved"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Data Preview",
|
||||
|
|
@ -236,6 +242,8 @@
|
|||
"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)",
|
||||
"isInputable": "Allow input",
|
||||
"isInputableHint": "Uncheck to hide from budget and transaction dropdowns",
|
||||
"sortOrder": "Sort Order",
|
||||
"selectCategory": "Select a category to view details",
|
||||
"keywordCount": "Keywords",
|
||||
|
|
@ -287,6 +295,7 @@
|
|||
"difference": "Difference",
|
||||
"annual": "Annual",
|
||||
"splitEvenly": "Split evenly across 12 months",
|
||||
"annualMismatch": "Annual total does not match the sum of monthly amounts",
|
||||
"applyToMonth": "Apply to month",
|
||||
"allMonths": "All 12 months",
|
||||
"expenses": "Expenses",
|
||||
|
|
|
|||
|
|
@ -84,7 +84,13 @@
|
|||
"selectFiles": "Fichiers à importer",
|
||||
"selectAll": "Tout sélectionner",
|
||||
"alreadyImported": "Importé",
|
||||
"autoDetect": "Auto-détecter"
|
||||
"autoDetect": "Auto-détecter",
|
||||
"saveAsTemplate": "Sauver comme modèle",
|
||||
"loadTemplate": "Charger un modèle",
|
||||
"templateName": "Nom du modèle",
|
||||
"templateSaved": "Modèle sauvegardé",
|
||||
"deleteTemplate": "Supprimer le modèle",
|
||||
"noTemplates": "Aucun modèle sauvegardé"
|
||||
},
|
||||
"preview": {
|
||||
"title": "Aperçu des données",
|
||||
|
|
@ -236,6 +242,8 @@
|
|||
"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)",
|
||||
"isInputable": "Autoriser la saisie",
|
||||
"isInputableHint": "Décocher pour masquer du budget et des listes de catégories",
|
||||
"sortOrder": "Ordre de tri",
|
||||
"selectCategory": "Sélectionnez une catégorie pour voir les détails",
|
||||
"keywordCount": "Mots-clés",
|
||||
|
|
@ -287,6 +295,7 @@
|
|||
"difference": "Écart",
|
||||
"annual": "Annuel",
|
||||
"splitEvenly": "Répartir également sur 12 mois",
|
||||
"annualMismatch": "Le total annuel ne correspond pas à la somme des montants mensuels",
|
||||
"applyToMonth": "Appliquer au mois",
|
||||
"allMonths": "Les 12 mois",
|
||||
"expenses": "Dépenses",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,9 @@ export default function ImportPage() {
|
|||
goToStep,
|
||||
reset,
|
||||
autoDetectConfig,
|
||||
saveConfigAsTemplate,
|
||||
applyConfigTemplate,
|
||||
deleteConfigTemplate,
|
||||
toggleDuplicateRow,
|
||||
setSkipAllDuplicates,
|
||||
} = useImportWizard();
|
||||
|
|
@ -89,10 +92,14 @@ export default function ImportPage() {
|
|||
selectedFiles={state.selectedFiles}
|
||||
importedFileNames={state.importedFilesBySource.get(state.selectedSource.folder_name)}
|
||||
headers={state.previewHeaders}
|
||||
configTemplates={state.configTemplates}
|
||||
onConfigChange={updateConfig}
|
||||
onFileToggle={toggleFile}
|
||||
onSelectAllFiles={selectAllFiles}
|
||||
onAutoDetect={autoDetectConfig}
|
||||
onSaveAsTemplate={saveConfigAsTemplate}
|
||||
onApplyTemplate={applyConfigTemplate}
|
||||
onDeleteTemplate={deleteConfigTemplate}
|
||||
isLoading={state.isLoading}
|
||||
/>
|
||||
<div className="flex items-center justify-between pt-6 border-t border-[var(--border)]">
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ function computeMonthDateRange(year: number, month: number) {
|
|||
export async function getActiveCategories(): Promise<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>(
|
||||
"SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name"
|
||||
"SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ interface CategoryRow {
|
|||
icon: string | null;
|
||||
type: "expense" | "income" | "transfer";
|
||||
is_active: boolean;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
keyword_count: number;
|
||||
}
|
||||
|
|
@ -30,12 +31,13 @@ export async function createCategory(data: {
|
|||
type: string;
|
||||
color: string;
|
||||
parent_id: number | null;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
}): Promise<number> {
|
||||
const db = await getDb();
|
||||
const result = await db.execute(
|
||||
`INSERT INTO categories (name, type, color, parent_id, sort_order) VALUES ($1, $2, $3, $4, $5)`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.sort_order]
|
||||
`INSERT INTO categories (name, type, color, parent_id, is_inputable, sort_order) VALUES ($1, $2, $3, $4, $5, $6)`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order]
|
||||
);
|
||||
return result.lastInsertId as number;
|
||||
}
|
||||
|
|
@ -47,13 +49,14 @@ export async function updateCategory(
|
|||
type: string;
|
||||
color: string;
|
||||
parent_id: number | null;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.execute(
|
||||
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, sort_order = $5 WHERE id = $6`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.sort_order, id]
|
||||
`UPDATE categories SET name = $1, type = $2, color = $3, parent_id = $4, is_inputable = $5, sort_order = $6 WHERE id = $7`,
|
||||
[data.name, data.type, data.color, data.parent_id, data.is_inputable ? 1 : 0, data.sort_order, id]
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
36
src/services/importConfigTemplateService.ts
Normal file
36
src/services/importConfigTemplateService.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { getDb } from "./db";
|
||||
import type { ImportConfigTemplate } from "../shared/types";
|
||||
|
||||
export async function getAllTemplates(): Promise<ImportConfigTemplate[]> {
|
||||
const db = await getDb();
|
||||
return db.select<ImportConfigTemplate[]>(
|
||||
"SELECT * FROM import_config_templates ORDER BY name"
|
||||
);
|
||||
}
|
||||
|
||||
export async function createTemplate(
|
||||
template: Omit<ImportConfigTemplate, "id" | "created_at">
|
||||
): Promise<number> {
|
||||
const db = await getDb();
|
||||
const result = await db.execute(
|
||||
`INSERT INTO import_config_templates (name, delimiter, encoding, date_format, skip_lines, has_header, column_mapping, amount_mode, sign_convention)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||
[
|
||||
template.name,
|
||||
template.delimiter,
|
||||
template.encoding,
|
||||
template.date_format,
|
||||
template.skip_lines,
|
||||
template.has_header,
|
||||
template.column_mapping,
|
||||
template.amount_mode,
|
||||
template.sign_convention,
|
||||
]
|
||||
);
|
||||
return result.lastInsertId as number;
|
||||
}
|
||||
|
||||
export async function deleteTemplate(id: number): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.execute("DELETE FROM import_config_templates WHERE id = $1", [id]);
|
||||
}
|
||||
|
|
@ -232,7 +232,7 @@ export async function updateTransactionNotes(
|
|||
export async function getAllCategories(): Promise<Category[]> {
|
||||
const db = await getDb();
|
||||
return db.select<Category[]>(
|
||||
`SELECT * FROM categories WHERE is_active = 1 ORDER BY sort_order, name`
|
||||
`SELECT * FROM categories WHERE is_active = 1 AND is_inputable = 1 ORDER BY sort_order, name`
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ export interface Category {
|
|||
icon?: string;
|
||||
type: "expense" | "income" | "transfer";
|
||||
is_active: boolean;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
|
@ -140,6 +141,20 @@ export interface BudgetYearRow {
|
|||
annual: number; // computed sum
|
||||
}
|
||||
|
||||
export interface ImportConfigTemplate {
|
||||
id: number;
|
||||
name: string;
|
||||
delimiter: string;
|
||||
encoding: string;
|
||||
date_format: string;
|
||||
skip_lines: number;
|
||||
has_header: number;
|
||||
column_mapping: string;
|
||||
amount_mode: AmountMode;
|
||||
sign_convention: SignConvention;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface UserPreference {
|
||||
key: string;
|
||||
value: string;
|
||||
|
|
@ -295,6 +310,7 @@ export interface CategoryTreeNode {
|
|||
icon: string | null;
|
||||
type: "expense" | "income" | "transfer";
|
||||
is_active: boolean;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
keyword_count: number;
|
||||
children: CategoryTreeNode[];
|
||||
|
|
@ -305,6 +321,7 @@ export interface CategoryFormData {
|
|||
type: "expense" | "income" | "transfer";
|
||||
color: string;
|
||||
parent_id: number | null;
|
||||
is_inputable: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue