5217835c55
* monthUtils.{format → nonLocalizedFormat} * Implement localization for schedule descriptions * Remove outdated comment * Add general.ordinal in Spanish Co-Authored-By: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com> * yay time zones? * fix: re-add missing keys * fix: fix broken i18n imports/initialisation * style: linting * fix: re-add english ordinal keys * fix: add remaining english ordinal keys * fix: correct dates in schedules.js * refactor: store translations keys for loot-core in loot-core * fix: add ns to i18n.t calls directly so parser can find them * feat: add spanish translation from manuelcanepa * fix: add comments to help i18n-parser to find contexts * fix: add "many" context to spanish translations Co-authored-by: Manuel Eduardo Cánepa Cihuelo <10290593+manuelcanepa@users.noreply.github.com> Co-authored-by: Tom French <tom@tomfren.ch>
886 lines
23 KiB
JavaScript
886 lines
23 KiB
JavaScript
import React, { useState, useEffect, useMemo } from 'react';
|
|
import { connect } from 'react-redux';
|
|
|
|
import * as d from 'date-fns';
|
|
|
|
import * as actions from 'loot-core/src/client/actions';
|
|
import { nonLocalizedFormat as formatDate_ } from 'loot-core/src/shared/months';
|
|
import {
|
|
amountToCurrency,
|
|
amountToInteger,
|
|
looselyParseAmount
|
|
} from 'loot-core/src/shared/util';
|
|
|
|
import { colors, styles } from '../../style';
|
|
import {
|
|
View,
|
|
Text,
|
|
Stack,
|
|
Modal,
|
|
Select,
|
|
Button,
|
|
ButtonWithLoading
|
|
} from '../common';
|
|
import { Checkbox, SectionLabel } from '../forms';
|
|
import { TableHeader, TableWithNavigator, Row, Field } from '../table';
|
|
|
|
let dateFormats = [
|
|
{ format: 'yyyy mm dd', label: 'YYYY MM DD' },
|
|
{ format: 'yy mm dd', label: 'YY MM DD' },
|
|
{ format: 'mm dd yyyy', label: 'MM DD YYYY' },
|
|
{ format: 'mm dd yy', label: 'MM DD YY' },
|
|
{ format: 'dd mm yyyy', label: 'DD MM YYYY' },
|
|
{ format: 'dd mm yy', label: 'DD MM YY' }
|
|
];
|
|
|
|
export function parseDate(str, order) {
|
|
if (typeof str !== 'string') {
|
|
return null;
|
|
}
|
|
|
|
function pad(v) {
|
|
return v && v.length === 1 ? '0' + v : v;
|
|
}
|
|
|
|
const dateGroups = (a, b) => str => {
|
|
const digits = str.replace(/[^\d]/g, '');
|
|
return [digits.slice(0, a), digits.slice(a, a + b), digits.slice(a + b)];
|
|
};
|
|
const yearFirst = dateGroups(4, 2);
|
|
const twoDig = dateGroups(2, 2);
|
|
|
|
let parts, year, month, day;
|
|
switch (order) {
|
|
case 'dd mm yyyy':
|
|
parts = twoDig(str);
|
|
year = parts[2];
|
|
month = parts[1];
|
|
day = parts[0];
|
|
break;
|
|
case 'dd mm yy':
|
|
parts = twoDig(str);
|
|
year = `20${parts[2]}`;
|
|
month = parts[1];
|
|
day = parts[0];
|
|
break;
|
|
case 'yyyy mm dd':
|
|
parts = yearFirst(str);
|
|
year = parts[0];
|
|
month = parts[1];
|
|
day = parts[2];
|
|
break;
|
|
case 'yy mm dd':
|
|
parts = twoDig(str);
|
|
year = `20${parts[0]}`;
|
|
month = parts[1];
|
|
day = parts[2];
|
|
break;
|
|
case 'mm dd yy':
|
|
parts = twoDig(str);
|
|
year = `20${parts[2]}`;
|
|
month = parts[0];
|
|
day = parts[1];
|
|
break;
|
|
default:
|
|
case 'mm dd yyyy':
|
|
parts = twoDig(str);
|
|
year = parts[2];
|
|
month = parts[0];
|
|
day = parts[1];
|
|
}
|
|
|
|
let parsed = `${year}-${pad(month)}-${pad(day)}`;
|
|
if (!d.isValid(d.parseISO(parsed))) {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
}
|
|
|
|
function formatDate(date, format) {
|
|
if (!date) {
|
|
return null;
|
|
}
|
|
try {
|
|
return formatDate_(date, format);
|
|
} catch (e) {}
|
|
return null;
|
|
}
|
|
|
|
function getFileType(filepath) {
|
|
let m = filepath.match(/\.([^.]*)$/);
|
|
return m ? m[1].toLowerCase() : 'ofx';
|
|
}
|
|
|
|
function ParsedDate({ parseDateFormat, showParsed, dateFormat, date }) {
|
|
let parsed =
|
|
date &&
|
|
formatDate(
|
|
parseDateFormat ? parseDate(date, parseDateFormat) : date,
|
|
dateFormat
|
|
);
|
|
return (
|
|
<Text>
|
|
<Text>
|
|
{date || (
|
|
<Text style={{ color: colors.n4, fontStyle: 'italic' }}>Empty</Text>
|
|
)}{' '}
|
|
→{' '}
|
|
</Text>
|
|
<Text style={{ color: parsed ? colors.g3 : colors.r4 }}>
|
|
{parsed || 'Invalid'}
|
|
</Text>
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function getInitialDateFormat(transactions, mappings) {
|
|
if (transactions.length === 0 || mappings.date == null) {
|
|
return 'yyyy mm dd';
|
|
}
|
|
|
|
let transaction = transactions[0];
|
|
let date = transaction[mappings.date];
|
|
|
|
let found =
|
|
date == null
|
|
? null
|
|
: dateFormats.find(f => parseDate(date, f.format) != null);
|
|
return found ? found.format : 'mm dd yyyy';
|
|
}
|
|
|
|
function getInitialMappings(transactions) {
|
|
if (transactions.length === 0) {
|
|
return {};
|
|
}
|
|
|
|
let transaction = transactions[0];
|
|
let fields = Object.entries(transaction);
|
|
|
|
function key(entry) {
|
|
return entry ? entry[0] : null;
|
|
}
|
|
|
|
let dateField = key(
|
|
fields.find(([name, value]) => name.toLowerCase().includes('date')) ||
|
|
fields.find(([name, value]) => value.match(/^\d+[-/]\d+[-/]\d+$/))
|
|
);
|
|
|
|
let amountField = key(
|
|
fields.find(([name, value]) => name.toLowerCase().includes('amount')) ||
|
|
fields.find(([name, value]) => value.match(/^-?[.,\d]+$/))
|
|
);
|
|
|
|
let payeeField = key(
|
|
fields.find(([name, value]) => name !== dateField && name !== amountField)
|
|
);
|
|
|
|
let notesField = key(
|
|
fields.find(
|
|
([name, value]) =>
|
|
name !== dateField && name !== amountField && name !== payeeField
|
|
)
|
|
);
|
|
|
|
return {
|
|
date: dateField,
|
|
amount: amountField,
|
|
payee: payeeField,
|
|
notes: notesField
|
|
};
|
|
}
|
|
|
|
function applyFieldMappings(transaction, mappings) {
|
|
let result = {};
|
|
for (let [field, target] of Object.entries(mappings)) {
|
|
if (field === 'payee') {
|
|
field = 'payee_name';
|
|
}
|
|
|
|
result[field] = transaction[target || field];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function parseAmount(amount, mapper) {
|
|
if (amount == null) {
|
|
return null;
|
|
}
|
|
let parsed = typeof amount === 'string' ? looselyParseAmount(amount) : amount;
|
|
let value = mapper(parsed);
|
|
return value;
|
|
}
|
|
|
|
function parseAmountFields(trans, splitMode, flipAmount) {
|
|
if (splitMode) {
|
|
// Split mode is a little weird; first we look for an outflow and
|
|
// if that has a value, we never want to show a number in the
|
|
// inflow. Same for `amount`; we choose outflow first and then inflow
|
|
let outflow = parseAmount(trans.outflow, n => -Math.abs(n));
|
|
let inflow = outflow ? 0 : parseAmount(trans.inflow, n => Math.abs(n));
|
|
|
|
return {
|
|
amount: outflow || inflow,
|
|
outflow,
|
|
inflow
|
|
};
|
|
}
|
|
return {
|
|
amount: parseAmount(trans.amount, n => (flipAmount ? n * -1 : n)),
|
|
outflow: null,
|
|
inflow: null
|
|
};
|
|
}
|
|
|
|
function Transaction({
|
|
transaction: rawTransaction,
|
|
fieldMappings,
|
|
showParsed,
|
|
parseDateFormat,
|
|
dateFormat,
|
|
splitMode,
|
|
flipAmount
|
|
}) {
|
|
let transaction = useMemo(
|
|
() =>
|
|
fieldMappings
|
|
? applyFieldMappings(rawTransaction, fieldMappings)
|
|
: rawTransaction,
|
|
[rawTransaction, fieldMappings]
|
|
);
|
|
|
|
let { amount, outflow, inflow } = parseAmountFields(
|
|
transaction,
|
|
splitMode,
|
|
flipAmount
|
|
);
|
|
amount = amountToCurrency(amount);
|
|
outflow = amountToCurrency(outflow);
|
|
inflow = amountToCurrency(inflow);
|
|
|
|
return (
|
|
<Row style={{ backgroundColor: 'white' }}>
|
|
<Field width={200} borderColor={colors.border}>
|
|
{showParsed ? (
|
|
<ParsedDate
|
|
parseDateFormat={parseDateFormat}
|
|
dateFormat={dateFormat}
|
|
date={transaction.date}
|
|
/>
|
|
) : (
|
|
formatDate(transaction.date, dateFormat)
|
|
)}
|
|
</Field>
|
|
<Field
|
|
width="flex"
|
|
borderColor={colors.border}
|
|
title={transaction.imported_payee || transaction.payee_name}
|
|
>
|
|
{transaction.payee_name}
|
|
</Field>
|
|
<Field width="flex" borderColor={colors.border} title={transaction.notes}>
|
|
{transaction.notes}
|
|
</Field>
|
|
{splitMode ? (
|
|
<>
|
|
<Field
|
|
width={90}
|
|
borderColor={colors.border}
|
|
contentStyle={[{ textAlign: 'right' }, styles.tnum]}
|
|
title={outflow}
|
|
>
|
|
{outflow}
|
|
</Field>
|
|
<Field
|
|
width={90}
|
|
borderColor={colors.border}
|
|
contentStyle={[{ textAlign: 'right' }, styles.tnum]}
|
|
title={inflow}
|
|
>
|
|
{inflow}
|
|
</Field>
|
|
</>
|
|
) : (
|
|
<Field
|
|
width={90}
|
|
borderColor={colors.border}
|
|
contentStyle={[{ textAlign: 'right' }, styles.tnum]}
|
|
title={amount}
|
|
>
|
|
{amount}
|
|
</Field>
|
|
)}
|
|
</Row>
|
|
);
|
|
}
|
|
|
|
function SubLabel({ title }) {
|
|
return (
|
|
<Text style={{ fontSize: 13, marginBottom: 3, color: colors.n3 }}>
|
|
{title}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
function SelectField({ width, style, options, value, onChange }) {
|
|
return (
|
|
<Select
|
|
value={value}
|
|
style={style}
|
|
onChange={e => onChange(e.target.value)}
|
|
>
|
|
<option value="">Choose field...</option>
|
|
{options.map(x => (
|
|
<option key={x} value={x}>
|
|
{x}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
);
|
|
}
|
|
|
|
function DateFormatSelect({
|
|
transactions,
|
|
fieldMappings,
|
|
parseDateFormat,
|
|
onChange
|
|
}) {
|
|
// We don't actually care about the delimiter, but we try to render
|
|
// it based on the data we have so far. Look in a transaction and
|
|
// try to figure out what delimiter the date is using, and default
|
|
// to space if we can't figure it out.
|
|
let delimiter = '-';
|
|
if (transactions.length > 0 && fieldMappings && fieldMappings.date != null) {
|
|
let date = transactions[0][fieldMappings.date];
|
|
let m = date && date.match(/[/.,-/\\]/);
|
|
delimiter = m ? m[0] : ' ';
|
|
}
|
|
|
|
return (
|
|
<View style={{ width: 120 }}>
|
|
<SectionLabel title="Date format" />
|
|
<Select
|
|
value={parseDateFormat || ''}
|
|
onChange={e => onChange(e.target.value)}
|
|
>
|
|
{dateFormats.map(f => (
|
|
<option key={f.format} value={f.format}>
|
|
{f.label.replace(/ /g, delimiter)}
|
|
</option>
|
|
))}
|
|
</Select>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FlipAmountOption({ value, disabled, onChange }) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
userSelect: 'none'
|
|
}}
|
|
>
|
|
<Checkbox
|
|
id="form_flip"
|
|
checked={value}
|
|
disabled={disabled}
|
|
onChange={onChange}
|
|
/>
|
|
<label
|
|
htmlFor="form_flip"
|
|
style={{ userSelect: 'none', color: disabled ? colors.n6 : null }}
|
|
>
|
|
Flip amount
|
|
</label>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SplitOption({ value, onChange }) {
|
|
return (
|
|
<View
|
|
style={{
|
|
flex: 1,
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
userSelect: 'none'
|
|
}}
|
|
>
|
|
<Checkbox id="form_split" checked={value} onChange={onChange} />
|
|
<label htmlFor="form_split" style={{ userSelect: 'none' }}>
|
|
Split amount into separate inflow/outflow columns
|
|
</label>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function FieldMappings({ transactions, mappings, onChange, splitMode }) {
|
|
if (transactions.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
let options = Object.keys(transactions[0]);
|
|
mappings = mappings || {};
|
|
|
|
return (
|
|
<View>
|
|
<SectionLabel title="CSV FIELDS" />
|
|
<Stack
|
|
direction="row"
|
|
align="flex-start"
|
|
spacing={1}
|
|
style={{ marginTop: 5 }}
|
|
>
|
|
<View style={{ width: 200 }}>
|
|
<SubLabel title="Date" />
|
|
<SelectField
|
|
width={200}
|
|
options={options}
|
|
value={mappings.date || ''}
|
|
style={{ marginRight: 5 }}
|
|
onChange={name => onChange('date', name)}
|
|
/>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<SubLabel title="Payee" />
|
|
<SelectField
|
|
width="flex"
|
|
options={options}
|
|
value={mappings.payee || ''}
|
|
style={{ marginRight: 5 }}
|
|
onChange={name => onChange('payee', name)}
|
|
/>
|
|
</View>
|
|
<View style={{ flex: 1 }}>
|
|
<SubLabel title="Notes" />
|
|
<SelectField
|
|
width="flex"
|
|
options={options}
|
|
value={mappings.notes || ''}
|
|
style={{ marginRight: 5 }}
|
|
onChange={name => onChange('notes', name)}
|
|
/>
|
|
</View>
|
|
{splitMode ? (
|
|
<>
|
|
<View style={{ width: 90 }}>
|
|
<SubLabel title="Outflow" />
|
|
<SelectField
|
|
width={90}
|
|
options={options}
|
|
value={mappings.outflow || ''}
|
|
onChange={name => onChange('outflow', name)}
|
|
/>
|
|
</View>
|
|
<View style={{ width: 90 }}>
|
|
<SubLabel title="Inflow" />
|
|
<SelectField
|
|
width={90}
|
|
options={options}
|
|
value={mappings.inflow || ''}
|
|
onChange={name => onChange('inflow', name)}
|
|
/>
|
|
</View>
|
|
</>
|
|
) : (
|
|
<View style={{ width: 90 }}>
|
|
<SubLabel title="Amount" />
|
|
<SelectField
|
|
width={90}
|
|
options={options}
|
|
value={mappings.amount || ''}
|
|
onChange={name => onChange('amount', name)}
|
|
/>
|
|
</View>
|
|
)}
|
|
</Stack>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export function ImportTransactions({
|
|
modalProps,
|
|
options,
|
|
dateFormat = 'MM/dd/yyyy',
|
|
prefs,
|
|
parseTransactions,
|
|
importTransactions,
|
|
getPayees,
|
|
savePrefs
|
|
}) {
|
|
let [loadingState, setLoadingState] = useState('parsing');
|
|
let [error, setError] = useState(null);
|
|
let [filename, setFilename] = useState(options.filename);
|
|
let [transactions, setTransactions] = useState([]);
|
|
let [filetype, setFileType] = useState(null);
|
|
let [fieldMappings, setFieldMappings] = useState(null);
|
|
let [splitMode, setSplitMode] = useState(false);
|
|
let [flipAmount, setFlipAmount] = useState(false);
|
|
let { accountId, onImported } = options;
|
|
|
|
// This cannot be set after parsing the file, because changing it
|
|
// requires re-parsing the file. This is different from the other
|
|
// options which are simple post-processing. That means if you
|
|
// parsed different files without closing the modal, it wouldn't
|
|
// re-read this.
|
|
let [csvDelimiter, setCsvDelimiter] = useState(
|
|
prefs[`csv-delimiter-${accountId}`] || ','
|
|
);
|
|
|
|
let [parseDateFormat, setParseDateFormat] = useState(null);
|
|
|
|
async function parse(filename, options) {
|
|
setLoadingState('parsing');
|
|
|
|
let { errors, transactions } = await parseTransactions(filename, options);
|
|
|
|
setLoadingState(null);
|
|
setError(null);
|
|
|
|
if (errors.length > 0) {
|
|
setError({
|
|
parsed: true,
|
|
message: errors[0].message || 'Internal error'
|
|
});
|
|
} else {
|
|
let filetype = getFileType(filename);
|
|
setFilename(filename);
|
|
setFileType(filetype);
|
|
|
|
if (filetype === 'csv' || filetype === 'qif') {
|
|
setFlipAmount(prefs[`flip-amount-${accountId}-${filetype}`] || false);
|
|
}
|
|
|
|
if (filetype === 'csv') {
|
|
let mappings = prefs[`csv-mappings-${accountId}`];
|
|
mappings = mappings
|
|
? JSON.parse(mappings)
|
|
: getInitialMappings(transactions);
|
|
|
|
setFieldMappings(mappings);
|
|
|
|
// Set initial split mode based on any saved mapping
|
|
let initialSplitMode = !!(mappings.outflow || mappings.inflow);
|
|
setSplitMode(initialSplitMode);
|
|
|
|
setParseDateFormat(
|
|
prefs[`parse-date-${accountId}-${filetype}`] ||
|
|
getInitialDateFormat(transactions, mappings)
|
|
);
|
|
} else if (filetype === 'qif') {
|
|
setParseDateFormat(
|
|
prefs[`parse-date-${accountId}-${filetype}`] ||
|
|
getInitialDateFormat(transactions, { date: 'date' })
|
|
);
|
|
} else {
|
|
setFieldMappings(null);
|
|
setParseDateFormat(null);
|
|
}
|
|
|
|
// Reverse the transactions because it's very common for them to
|
|
// be ordered ascending, but we show transactions descending by
|
|
// date. This is purely cosmetic.
|
|
setTransactions(transactions.reverse());
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
parse(
|
|
options.filename,
|
|
getFileType(options.filename) === 'csv'
|
|
? { delimiter: csvDelimiter }
|
|
: null
|
|
);
|
|
}, [parseTransactions, options.filename]);
|
|
|
|
function onSplitMode() {
|
|
if (fieldMappings == null) {
|
|
return;
|
|
}
|
|
|
|
let isSplit = !splitMode;
|
|
setSplitMode(isSplit);
|
|
|
|
// Run auto-detection on the fields to try to detect the fields
|
|
// automatically
|
|
let mappings = getInitialMappings(transactions);
|
|
|
|
let newFieldMappings = isSplit
|
|
? {
|
|
amount: null,
|
|
outflow: mappings.amount,
|
|
inflow: null
|
|
}
|
|
: {
|
|
amount: mappings.amount,
|
|
outflow: null,
|
|
inflow: null
|
|
};
|
|
setFieldMappings({ ...fieldMappings, ...newFieldMappings });
|
|
}
|
|
|
|
function onNewFile() {
|
|
const res = window.Actual.openFileDialog({
|
|
filters: [
|
|
{ name: 'Financial Files', extensions: ['qif', 'ofx', 'qfx', 'csv'] }
|
|
]
|
|
});
|
|
|
|
parse(
|
|
res[0],
|
|
getFileType(res[0]) === 'csv' ? { delimiter: csvDelimiter } : null
|
|
);
|
|
}
|
|
|
|
function onUpdateFields(field, name) {
|
|
setFieldMappings({ ...fieldMappings, [field]: name === '' ? null : name });
|
|
}
|
|
|
|
async function onImport() {
|
|
setLoadingState('importing');
|
|
|
|
let finalTransactions = [];
|
|
let errorMessage;
|
|
|
|
for (let trans of transactions) {
|
|
trans = fieldMappings ? applyFieldMappings(trans, fieldMappings) : trans;
|
|
|
|
let date =
|
|
filetype === 'qfx' || filetype === 'ofx'
|
|
? trans.date
|
|
: parseDate(trans.date, parseDateFormat);
|
|
if (date == null) {
|
|
errorMessage = `Unable to parse date ${trans.date ||
|
|
'(empty)'} with given date format`;
|
|
break;
|
|
}
|
|
|
|
let { amount } = parseAmountFields(trans, splitMode, flipAmount);
|
|
if (amount == null) {
|
|
errorMessage = `Transaction on ${trans.date} has no amount`;
|
|
break;
|
|
}
|
|
|
|
let { inflow, outflow, ...finalTransaction } = trans;
|
|
finalTransactions.push({
|
|
...finalTransaction,
|
|
date,
|
|
amount: amountToInteger(amount)
|
|
});
|
|
}
|
|
|
|
if (errorMessage) {
|
|
setLoadingState(null);
|
|
setError({ parsed: false, message: errorMessage });
|
|
return;
|
|
}
|
|
|
|
if (filetype !== 'ofx' && filetype !== 'qfx') {
|
|
let key = `parse-date-${accountId}-${filetype}`;
|
|
savePrefs({ [key]: parseDateFormat });
|
|
}
|
|
|
|
if (filetype === 'csv') {
|
|
savePrefs({
|
|
[`csv-mappings-${accountId}`]: JSON.stringify(fieldMappings)
|
|
});
|
|
savePrefs({ [`csv-delimiter-${accountId}`]: csvDelimiter });
|
|
}
|
|
|
|
if (filetype === 'csv' || filetype === 'qif') {
|
|
savePrefs({ [`flip-amount-${accountId}-${filetype}`]: flipAmount });
|
|
}
|
|
|
|
let didChange = await importTransactions(accountId, finalTransactions);
|
|
if (didChange) {
|
|
await getPayees();
|
|
}
|
|
|
|
if (onImported) {
|
|
onImported(didChange);
|
|
}
|
|
|
|
modalProps.onClose();
|
|
}
|
|
|
|
let headers = [
|
|
{ name: 'Date', width: 200 },
|
|
{ name: 'Payee', width: 'flex' },
|
|
{ name: 'Notes', width: 'flex' }
|
|
];
|
|
|
|
if (splitMode) {
|
|
headers.push({ name: 'Outflow', width: 90, style: { textAlign: 'right' } });
|
|
headers.push({ name: 'Inflow', width: 90, style: { textAlign: 'right' } });
|
|
} else {
|
|
headers.push({ name: 'Amount', width: 90, style: { textAlign: 'right' } });
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
title={
|
|
'Import transactions' + (filetype ? ` (${filetype.toUpperCase()})` : '')
|
|
}
|
|
{...modalProps}
|
|
loading={loadingState === 'parsing'}
|
|
style={{ width: 800 }}
|
|
>
|
|
{error && !error.parsed && (
|
|
<View style={{ alignItems: 'center', marginBottom: 15 }}>
|
|
<Text style={{ marginRight: 10, color: colors.r4 }}>
|
|
<strong>Error:</strong> {error.message}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{(!error || !error.parsed) && (
|
|
<View
|
|
style={{
|
|
flex: 'unset',
|
|
height: 300,
|
|
border: '1px solid ' + colors.border
|
|
}}
|
|
>
|
|
<TableHeader headers={headers} />
|
|
|
|
<TableWithNavigator
|
|
items={transactions}
|
|
fields={['payee', 'amount']}
|
|
style={{ backgroundColor: colors.n11 }}
|
|
getItemKey={index => index}
|
|
renderEmpty={() => {
|
|
return (
|
|
<View
|
|
style={{
|
|
textAlign: 'center',
|
|
marginTop: 25,
|
|
color: colors.n4,
|
|
fontStyle: 'italic'
|
|
}}
|
|
>
|
|
No transactions found
|
|
</View>
|
|
);
|
|
}}
|
|
renderItem={({ key, style, item, editing, focusedField }) => (
|
|
<View key={key} style={style}>
|
|
<Transaction
|
|
transaction={item}
|
|
showParsed={filetype === 'csv' || filetype === 'qif'}
|
|
parseDateFormat={parseDateFormat}
|
|
dateFormat={dateFormat}
|
|
fieldMappings={fieldMappings}
|
|
splitMode={splitMode}
|
|
flipAmount={flipAmount}
|
|
/>
|
|
</View>
|
|
)}
|
|
/>
|
|
</View>
|
|
)}
|
|
{error && error.parsed && (
|
|
<View
|
|
style={{
|
|
color: colors.r4,
|
|
alignItems: 'center',
|
|
marginTop: 10
|
|
}}
|
|
>
|
|
<Text style={{ maxWidth: 450, marginBottom: 15 }}>
|
|
<strong>Error:</strong> {error.message}
|
|
</Text>
|
|
{error.parsed && (
|
|
<Button onClick={() => onNewFile()}>Select new file...</Button>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{filetype === 'csv' && (
|
|
<View style={{ marginTop: 25 }}>
|
|
<FieldMappings
|
|
transactions={transactions}
|
|
onChange={onUpdateFields}
|
|
mappings={fieldMappings}
|
|
splitMode={splitMode}
|
|
/>
|
|
</View>
|
|
)}
|
|
|
|
{(filetype === 'qif' || filetype === 'csv') && (
|
|
<View style={{ marginTop: 25 }}>
|
|
<SectionLabel title="IMPORT OPTIONS" />
|
|
<View style={{ marginTop: 5 }}>
|
|
<FlipAmountOption
|
|
value={flipAmount}
|
|
disabled={splitMode}
|
|
onChange={() => {
|
|
setFlipAmount(!flipAmount);
|
|
}}
|
|
/>
|
|
</View>
|
|
{filetype === 'csv' && (
|
|
<View style={{ marginTop: 10 }}>
|
|
<SplitOption value={splitMode} onChange={onSplitMode} />
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
<View style={{ flexDirection: 'row', marginTop: 25 }}>
|
|
{(filetype === 'qif' || filetype === 'csv') && (
|
|
<DateFormatSelect
|
|
transactions={transactions}
|
|
fieldMappings={fieldMappings}
|
|
parseDateFormat={parseDateFormat}
|
|
onChange={setParseDateFormat}
|
|
/>
|
|
)}
|
|
|
|
{filetype === 'csv' && (
|
|
<View style={{ marginLeft: 25 }}>
|
|
<SectionLabel title="CSV DELIMITER" />
|
|
<Select
|
|
value={csvDelimiter}
|
|
onChange={e => {
|
|
setCsvDelimiter(e.target.value);
|
|
parse(filename, { delimiter: e.target.value });
|
|
}}
|
|
>
|
|
<option value=",">,</option>
|
|
<option value=";">;</option>
|
|
</Select>
|
|
</View>
|
|
)}
|
|
|
|
<View style={{ flex: 1 }} />
|
|
|
|
<View
|
|
style={{
|
|
alignSelf: 'flex-end',
|
|
flexDirection: 'row',
|
|
alignItems: 'center'
|
|
}}
|
|
>
|
|
<ButtonWithLoading
|
|
primary
|
|
disabled={transactions.length === 0}
|
|
loading={loadingState === 'importing'}
|
|
onClick={onImport}
|
|
>
|
|
Import {transactions.length} transactions
|
|
</ButtonWithLoading>
|
|
</View>
|
|
</View>
|
|
</Modal>
|
|
);
|
|
}
|
|
|
|
export default connect(
|
|
state => ({
|
|
dateFormat: state.prefs.local.dateFormat || 'MM/dd/yyyy',
|
|
prefs: state.prefs.local
|
|
}),
|
|
actions
|
|
)(ImportTransactions);
|