ci / lint-test (push) Successful in 41s
Deleting the Frollo importer dropped its currency handling and nothing replaced it: ColumnMapping had no currency column, so applyMapping could never produce foreign_currency_code and batchInsertCSVTransactions' support for it was unreachable from the UI. The USD 10,782 salary imported as A$10,782 — about a third under, sitting in a column of AUD figures looking entirely normal, which is the same defect the owner spotted in the first place. An optional Currency column now sets foreign_currency_code and foreign_currency_amount when the cell is a three-letter code other than AUD, and leaves amount_aud NULL rather than inventing a rate. That is the shape order ingestion already uses and what AMOUNT_UNCONVERTED looks for, so the row renders as its native figure with "no AUD rate" and the statement supplies the real number when it arrives. Caught by reading the imported row rather than the import summary: the summary said 171 inserted and was right about everything it reported.
479 lines
24 KiB
TypeScript
479 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useRef, useEffect } from "react";
|
||
import { CATEGORIES, formatCategory } from "@/lib/categories";
|
||
import { useImportCSV, useStatementCoverage } from "@/lib/hooks";
|
||
import {
|
||
parseCSVRows, detectHasHeaders, getColumnLabels, getDataRows, applyMapping,
|
||
saveBankPreset, loadBankPresets, splitByCoverage, inFileDuplicates,
|
||
type DateFormat, type ColumnMapping, type ParsedTransaction, type BankPreset,
|
||
} from "@/lib/csv-parser";
|
||
|
||
const DATE_FORMATS: DateFormat[] = ["DD/MM/YYYY", "YYYY-MM-DD", "MM/DD/YYYY", "M/D/YYYY"];
|
||
const TX_TYPES = ["debit", "credit", "payment", "refund", "fee", "interest", "transfer"];
|
||
|
||
type Step = "upload" | "map" | "review" | "done";
|
||
|
||
|
||
|
||
function ColSelect({
|
||
label, value, onChange, options, required,
|
||
}: {
|
||
label: string; value: string; onChange: (v: string) => void;
|
||
options: string[]; required?: boolean;
|
||
}) {
|
||
return (
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-1">{label}</label>
|
||
<select
|
||
value={value}
|
||
onChange={(e) => onChange(e.target.value)}
|
||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{!required && <option value="">— none —</option>}
|
||
{options.map((o) => <option key={o} value={o}>{o}</option>)}
|
||
</select>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function CsvImportModal({ onClose }: { onClose: () => void }) {
|
||
const importCSV = useImportCSV();
|
||
const fileRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [step, setStep] = useState<Step>("upload");
|
||
const [rawRows, setRawRows] = useState<string[][]>([]);
|
||
const [hasHeaders, setHasHeaders] = useState(false);
|
||
const [columnLabels, setColumnLabels] = useState<string[]>([]);
|
||
const [dataRows, setDataRows] = useState<string[][]>([]);
|
||
const [bankName, setBankName] = useState("");
|
||
const [dateFormat, setDateFormat] = useState<DateFormat>("DD/MM/YYYY");
|
||
const [mapping, setMapping] = useState<ColumnMapping>({
|
||
dateCol: "", descriptionCol: "", amountMode: "single", amountCol: "",
|
||
});
|
||
const [savePreset, setSavePreset] = useState(false);
|
||
const [editedRows, setEditedRows] = useState<ParsedTransaction[]>([]);
|
||
const [error, setError] = useState("");
|
||
const [presets, setPresets] = useState<BankPreset[]>([]);
|
||
const [insertedCount, setInsertedCount] = useState(0);
|
||
const [coveredRows, setCoveredRows] = useState<ParsedTransaction[]>([]);
|
||
const [showCovered, setShowCovered] = useState(false);
|
||
const coverage = useStatementCoverage();
|
||
|
||
useEffect(() => { setPresets(loadBankPresets()); }, []);
|
||
|
||
function handleFile(file: File) {
|
||
setError("");
|
||
const reader = new FileReader();
|
||
reader.onload = (e) => {
|
||
const text = e.target?.result as string;
|
||
const rows = parseCSVRows(text);
|
||
if (rows.length === 0) { setError("No data found in file"); return; }
|
||
setRawRows(rows);
|
||
const headers = detectHasHeaders(rows, dateFormat);
|
||
setHasHeaders(headers);
|
||
const labels = getColumnLabels(rows, headers);
|
||
setColumnLabels(labels);
|
||
setDataRows(getDataRows(rows, headers));
|
||
// auto-set first columns as defaults
|
||
setMapping((m) => ({
|
||
...m,
|
||
dateCol: labels[0] ?? "",
|
||
descriptionCol: labels[1] ?? "",
|
||
amountCol: labels[2] ?? "",
|
||
}));
|
||
setStep("map");
|
||
};
|
||
reader.readAsText(file);
|
||
}
|
||
|
||
function applyPreset(preset: BankPreset) {
|
||
setBankName(preset.bankName);
|
||
setDateFormat(preset.dateFormat);
|
||
setMapping(preset.mapping);
|
||
}
|
||
|
||
function refreshColumns() {
|
||
if (!rawRows.length) return;
|
||
const headers = detectHasHeaders(rawRows, dateFormat);
|
||
setHasHeaders(headers);
|
||
const labels = getColumnLabels(rawRows, headers);
|
||
setColumnLabels(labels);
|
||
setDataRows(getDataRows(rawRows, headers));
|
||
}
|
||
|
||
function handleNext() {
|
||
setError("");
|
||
if (!bankName.trim()) { setError("Bank name is required"); return; }
|
||
if (!mapping.dateCol) { setError("Date column is required"); return; }
|
||
if (!mapping.descriptionCol) { setError("Description column is required"); return; }
|
||
if (mapping.amountMode === "single" && !mapping.amountCol) { setError("Amount column is required"); return; }
|
||
if (mapping.amountMode === "debit_credit" && !mapping.debitCol && !mapping.creditCol) {
|
||
setError("At least one of debit/credit columns is required"); return;
|
||
}
|
||
const parsed = applyMapping(dataRows, columnLabels, mapping, dateFormat);
|
||
if (parsed.length === 0) { setError("No valid transactions could be parsed — check your column mapping and date format"); return; }
|
||
if (savePreset) {
|
||
saveBankPreset({ bankName: bankName.trim(), mapping, dateFormat });
|
||
}
|
||
const { keep, covered } = splitByCoverage(parsed, coverage.data ?? []);
|
||
setEditedRows(keep);
|
||
setCoveredRows(covered);
|
||
setStep("review");
|
||
}
|
||
|
||
async function handleImport() {
|
||
setError("");
|
||
const valid = editedRows.filter((r) => r.date && r.amount > 0 && r.description);
|
||
if (!valid.length) { setError("No valid rows to import"); return; }
|
||
try {
|
||
const result = await importCSV.mutateAsync({
|
||
bank_name: bankName,
|
||
// Provenance, so a second import of the same file inserts nothing —
|
||
// paired with each row's source_ref where the file carries an id.
|
||
source: bankName.trim().toLowerCase().replace(/\s+/g, "-"),
|
||
transactions: valid,
|
||
});
|
||
setInsertedCount(result.inserted);
|
||
setStep("done");
|
||
} catch (e) {
|
||
setError(e instanceof Error ? e.message : "Import failed");
|
||
}
|
||
}
|
||
|
||
function updateRow(i: number, patch: Partial<ParsedTransaction>) {
|
||
setEditedRows((rows) => rows.map((r, idx) => idx === i ? { ...r, ...patch } : r));
|
||
}
|
||
|
||
function deleteRow(i: number) {
|
||
setEditedRows((rows) => rows.filter((_, idx) => idx !== i));
|
||
}
|
||
|
||
const modalWidth = step === "review" ? "max-w-5xl" : step === "map" ? "max-w-xl" : "max-w-md";
|
||
|
||
return (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||
<div
|
||
className={`bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl w-full ${modalWidth} flex flex-col max-h-[90vh]`}
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-800 flex-shrink-0">
|
||
<div>
|
||
<h3 className="font-semibold text-sm text-zinc-200">Import CSV</h3>
|
||
<div className="flex gap-2 mt-1">
|
||
{(["upload", "map", "review", "done"] as Step[]).map((s, i) => (
|
||
<span
|
||
key={s}
|
||
className={`text-xs ${step === s ? "text-indigo-400 font-medium" : "text-zinc-600"}`}
|
||
>
|
||
{i + 1}. {s.charAt(0).toUpperCase() + s.slice(1)}
|
||
</span>
|
||
))}
|
||
</div>
|
||
</div>
|
||
<button onClick={onClose} className="text-zinc-500 hover:text-zinc-300 text-xl leading-none">×</button>
|
||
</div>
|
||
|
||
{/* Body */}
|
||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||
|
||
{/* Step 1: Upload */}
|
||
{step === "upload" && (
|
||
<div className="space-y-4">
|
||
{presets.length > 0 && (
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-1">Load saved preset</label>
|
||
<select
|
||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||
defaultValue=""
|
||
onChange={(e) => {
|
||
const p = presets.find((x) => x.bankName === e.target.value);
|
||
if (p) applyPreset(p);
|
||
}}
|
||
>
|
||
<option value="">— select preset —</option>
|
||
{presets.map((p) => <option key={p.bankName} value={p.bankName}>{p.bankName}</option>)}
|
||
</select>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<input
|
||
ref={fileRef}
|
||
type="file"
|
||
accept=".csv,text/csv"
|
||
className="hidden"
|
||
onChange={(e) => { const f = e.target.files?.[0]; if (f) handleFile(f); }}
|
||
/>
|
||
<button
|
||
onClick={() => fileRef.current?.click()}
|
||
className="w-full border-2 border-dashed border-zinc-700 hover:border-indigo-500 rounded-xl py-12 text-center transition-colors"
|
||
onDragOver={(e) => e.preventDefault()}
|
||
onDrop={(e) => { e.preventDefault(); const f = e.dataTransfer.files?.[0]; if (f) handleFile(f); }}
|
||
>
|
||
<p className="text-zinc-400 text-sm">Drop a CSV file here, or click to browse</p>
|
||
<p className="text-zinc-600 text-xs mt-1">Westpac, ANZ, CBA, NAB and others</p>
|
||
</button>
|
||
</div>
|
||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 2: Map */}
|
||
{step === "map" && (
|
||
<div className="space-y-4">
|
||
{/* Raw preview */}
|
||
<div>
|
||
<p className="text-xs text-zinc-500 mb-2">First 3 rows from file:</p>
|
||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||
<table className="text-xs text-zinc-400 w-full">
|
||
<thead>
|
||
<tr className="border-b border-zinc-800">
|
||
{columnLabels.map((h) => (
|
||
<th key={h} className="px-2 py-1.5 text-left font-medium text-zinc-300 whitespace-nowrap">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{dataRows.slice(0, 3).map((row, i) => (
|
||
<tr key={i} className="border-b border-zinc-800/50">
|
||
{columnLabels.map((_, ci) => (
|
||
<td key={ci} className="px-2 py-1 truncate max-w-[160px]">{row[ci] ?? ""}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-1">Bank Name</label>
|
||
<input
|
||
value={bankName}
|
||
onChange={(e) => setBankName(e.target.value)}
|
||
placeholder="e.g. Westpac"
|
||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-1">Date Format</label>
|
||
<select
|
||
value={dateFormat}
|
||
onChange={(e) => { setDateFormat(e.target.value as DateFormat); refreshColumns(); }}
|
||
className="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1.5 text-sm"
|
||
>
|
||
{DATE_FORMATS.map((f) => <option key={f} value={f}>{f}</option>)}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<ColSelect label="Date Column *" value={mapping.dateCol} onChange={(v) => setMapping((m) => ({ ...m, dateCol: v }))} options={columnLabels} required />
|
||
<ColSelect label="Description Column *" value={mapping.descriptionCol} onChange={(v) => setMapping((m) => ({ ...m, descriptionCol: v }))} options={columnLabels} required />
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-xs text-zinc-500 mb-2">Amount</label>
|
||
<div className="flex gap-4 mb-2">
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input type="radio" name="amtmode" value="single" checked={mapping.amountMode === "single"} onChange={() => setMapping((m) => ({ ...m, amountMode: "single" }))} className="accent-indigo-500" />
|
||
Single signed column
|
||
</label>
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||
<input type="radio" name="amtmode" value="debit_credit" checked={mapping.amountMode === "debit_credit"} onChange={() => setMapping((m) => ({ ...m, amountMode: "debit_credit" }))} className="accent-indigo-500" />
|
||
Separate debit / credit columns
|
||
</label>
|
||
</div>
|
||
{mapping.amountMode === "single" ? (
|
||
<ColSelect label="Amount Column *" value={mapping.amountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, amountCol: v }))} options={columnLabels} required />
|
||
) : (
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<ColSelect label="Debit Column" value={mapping.debitCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, debitCol: v }))} options={columnLabels} />
|
||
<ColSelect label="Credit Column" value={mapping.creditCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, creditCol: v }))} options={columnLabels} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<ColSelect label="Merchant Column (optional)" value={mapping.merchantCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, merchantCol: v || undefined }))} options={columnLabels} />
|
||
<ColSelect label="Category Column (optional)" value={mapping.categoryCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, categoryCol: v || undefined }))} options={columnLabels} />
|
||
<ColSelect label="Account Column (optional)" value={mapping.accountCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, accountCol: v || undefined }))} options={columnLabels} />
|
||
<ColSelect label="Currency Column (optional)" value={mapping.currencyCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, currencyCol: v || undefined }))} options={columnLabels} />
|
||
<ColSelect label="Row ID Column (optional)" value={mapping.sourceRefCol ?? ""} onChange={(v) => setMapping((m) => ({ ...m, sourceRefCol: v || undefined }))} options={columnLabels} />
|
||
</div>
|
||
<p className="text-[11px] text-zinc-500 leading-relaxed">
|
||
Map <b>Account</b> when the file covers more than one account: rows already
|
||
covered by that account's statements are then left out. Map <b>Row ID</b> to
|
||
make re-importing the same file do nothing. Map <b>Currency</b> for a
|
||
multi-currency file, or foreign rows are stored as if they were AUD.
|
||
</p>
|
||
|
||
<label className="flex items-center gap-2 text-sm cursor-pointer text-zinc-400">
|
||
<input type="checkbox" checked={savePreset} onChange={(e) => setSavePreset(e.target.checked)} className="accent-indigo-500" />
|
||
Save as preset for {bankName || "this bank"}
|
||
</label>
|
||
|
||
{error && <p className="text-red-400 text-xs">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 3: Review */}
|
||
{step === "review" && (
|
||
<div>
|
||
<p className="text-xs text-zinc-500 mb-2">
|
||
{editedRows.length} transactions to import. Edit or remove rows before importing.
|
||
</p>
|
||
|
||
{/*
|
||
Reported, never silent. A row that disappears can mean several
|
||
things and one of them is worth following up, so the count is
|
||
always shown and the rows themselves are one click away.
|
||
*/}
|
||
{coveredRows.length > 0 && (
|
||
<div className="mb-3 rounded border border-zinc-800 bg-zinc-800/30 px-3 py-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowCovered((v) => !v)}
|
||
className="text-xs text-zinc-300 hover:text-white text-left w-full"
|
||
>
|
||
{coveredRows.length} row{coveredRows.length === 1 ? "" : "s"} left out — already covered by
|
||
a statement for that account.{" "}
|
||
<span className="text-indigo-400">{showCovered ? "hide" : "show"}</span>
|
||
</button>
|
||
{showCovered && (
|
||
<>
|
||
<ul className="mt-2 max-h-40 overflow-y-auto text-[11px] text-zinc-500 font-mono space-y-0.5">
|
||
{coveredRows.slice(0, 200).map((r, i) => (
|
||
<li key={i}>{r.date} {r.amount.toFixed(2).padStart(10)} {r.description.slice(0, 44)}</li>
|
||
))}
|
||
</ul>
|
||
{coveredRows.length > 200 && (
|
||
<p className="mt-1 text-[11px] text-zinc-600">…and {coveredRows.length - 200} more.</p>
|
||
)}
|
||
<p className="mt-2 text-[11px] text-zinc-500">
|
||
Statements cover:{" "}
|
||
{(coverage.data ?? []).map((c) => `${c.last4} to ${c.coveredTo}`).join(" · ")}
|
||
</p>
|
||
</>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{inFileDuplicates(editedRows) > 0 && (
|
||
<p className="mb-3 rounded border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-xs text-amber-300">
|
||
{inFileDuplicates(editedRows)} row{inFileDuplicates(editedRows) === 1 ? " duplicates another row" : "s duplicate other rows"} in
|
||
this same file (same date, amount and description). A re-consent can make an
|
||
aggregator re-export history under new ids — check before importing.
|
||
</p>
|
||
)}
|
||
<div className="overflow-x-auto rounded border border-zinc-800">
|
||
<table className="w-full text-xs">
|
||
<thead className="border-b border-zinc-800">
|
||
<tr>
|
||
{["Date", "Description", "Amount", "Type", "Merchant", "Category", ""].map((h) => (
|
||
<th key={h} className="px-2 py-2 text-left text-zinc-400 font-medium whitespace-nowrap">{h}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{editedRows.map((row, i) => (
|
||
<tr key={i} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
|
||
<td className="px-2 py-1">
|
||
<input type="date" value={row.date} onChange={(e) => updateRow(i, { date: e.target.value })}
|
||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<input value={row.description} onChange={(e) => updateRow(i, { description: e.target.value })}
|
||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-48 focus:outline-none focus:border-indigo-500" />
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<input type="number" step="0.01" value={row.amount} onChange={(e) => updateRow(i, { amount: parseFloat(e.target.value) || 0 })}
|
||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-20 focus:outline-none focus:border-indigo-500" />
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<select value={row.transaction_type} onChange={(e) => updateRow(i, { transaction_type: e.target.value })}
|
||
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||
{TX_TYPES.map((t) => <option key={t} value={t}>{t}</option>)}
|
||
</select>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<input value={row.merchant_name ?? ""} onChange={(e) => updateRow(i, { merchant_name: e.target.value || undefined })}
|
||
className="bg-transparent border-b border-zinc-700 text-zinc-300 text-xs w-28 focus:outline-none focus:border-indigo-500" />
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<select value={row.category ?? ""} onChange={(e) => updateRow(i, { category: e.target.value || undefined })}
|
||
className="bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-xs">
|
||
<option value="">—</option>
|
||
{CATEGORIES.map((c) => <option key={c} value={c}>{formatCategory(c)}</option>)}
|
||
</select>
|
||
</td>
|
||
<td className="px-2 py-1">
|
||
<button onClick={() => deleteRow(i)} className="text-zinc-600 hover:text-red-400 text-base leading-none">×</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{error && <p className="text-red-400 text-xs mt-2">{error}</p>}
|
||
</div>
|
||
)}
|
||
|
||
{/* Step 4: Done */}
|
||
{step === "done" && (
|
||
<div className="text-center py-6 space-y-3">
|
||
<div className="text-4xl">✓</div>
|
||
<p className="text-zinc-200 font-medium">Imported {insertedCount} transactions</p>
|
||
<p className="text-zinc-500 text-sm">Tagged with <span className="text-indigo-400">csv-import</span></p>
|
||
<div className="flex gap-2 justify-center pt-2">
|
||
<a href="/transactions" className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||
View Transactions
|
||
</a>
|
||
<a href="/reconcile" className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm">
|
||
Reconcile
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
{step !== "done" && (
|
||
<div className="flex gap-2 px-6 py-4 border-t border-zinc-800 flex-shrink-0">
|
||
{step === "map" && (
|
||
<button onClick={() => setStep("upload")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||
Back
|
||
</button>
|
||
)}
|
||
{step === "review" && (
|
||
<button onClick={() => setStep("map")} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||
Back
|
||
</button>
|
||
)}
|
||
<div className="flex-1" />
|
||
<button onClick={onClose} className="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded-lg text-sm">
|
||
Cancel
|
||
</button>
|
||
{step === "map" && (
|
||
<button onClick={handleNext} className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium">
|
||
Next →
|
||
</button>
|
||
)}
|
||
{step === "review" && (
|
||
<button
|
||
onClick={handleImport}
|
||
disabled={importCSV.isPending || editedRows.length === 0}
|
||
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white rounded-lg text-sm font-medium disabled:opacity-50"
|
||
>
|
||
{importCSV.isPending ? "Importing..." : `Import ${editedRows.length} transactions`}
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|