ScheduleDetails.jsx 24.48 KiB
import React, { useEffect, useReducer } from 'react';
import { useDispatch } from 'react-redux';
import { getPayeesById } from 'loot-core/client/reducers/queries';
import { pushModal } from 'loot-core/src/client/actions/modals';
import { runQuery, liveQuery } from 'loot-core/src/client/query-helpers';
import { send, sendCatch } from 'loot-core/src/platform/client/fetch';
import * as monthUtils from 'loot-core/src/shared/months';
import { q } from 'loot-core/src/shared/query';
import { extractScheduleConds } from 'loot-core/src/shared/schedules';
import { useDateFormat } from '../../hooks/useDateFormat';
import { usePayees } from '../../hooks/usePayees';
import { useSelected, SelectedProvider } from '../../hooks/useSelected';
import { theme } from '../../style';
import { AccountAutocomplete } from '../autocomplete/AccountAutocomplete';
import { PayeeAutocomplete } from '../autocomplete/PayeeAutocomplete';
import { Button } from '../common/Button';
import { Modal } from '../common/Modal';
import { Stack } from '../common/Stack';
import { Text } from '../common/Text';
import { View } from '../common/View';
import { FormField, FormLabel, Checkbox } from '../forms';
import { OpSelect } from '../modals/EditRule';
import { DateSelect } from '../select/DateSelect';
import { RecurringSchedulePicker } from '../select/RecurringSchedulePicker';
import { SelectedItemsButton } from '../table';
import { SimpleTransactionsTable } from '../transactions/SimpleTransactionsTable';
import { AmountInput, BetweenAmountInput } from '../util/AmountInput';
import { GenericInput } from '../util/GenericInput';
function updateScheduleConditions(schedule, fields) {
const conds = extractScheduleConds(schedule._conditions);
const updateCond = (cond, op, field, value) => {
if (cond) {
return { ...cond, value };
}
if (value != null) {
return { op, field, value };
}
return null;
};
// Validate
if (fields.date == null) {
return { error: 'Date is required' };
}
if (fields.amount == null) {
return { error: 'A valid amount is required' };
}
return {
conditions: [
updateCond(conds.payee, 'is', 'payee', fields.payee),
updateCond(conds.account, 'is', 'account', fields.account),
updateCond(conds.date, 'isapprox', 'date', fields.date),
// We don't use `updateCond` for amount because we want to
// overwrite it completely
{
op: fields.amountOp,
field: 'amount',
value: fields.amount,
},
].filter(Boolean),
};
}
export function ScheduleDetails({ modalProps, actions, id, transaction }) {
const adding = id == null;
const fromTrans = transaction != null;
const payees = getPayeesById(usePayees());
const globalDispatch = useDispatch();
const dateFormat = useDateFormat() || 'MM/dd/yyyy';
const [state, dispatch] = useReducer(
(state, action) => {
switch (action.type) {
case 'set-schedule': {
const schedule = action.schedule;
// See if there are custom rules
const conds = extractScheduleConds(schedule._conditions);
const condsSet = new Set(Object.values(conds));
const isCustom =
schedule._conditions.find(c => !condsSet.has(c)) ||
schedule._actions.find(a => a.op !== 'link-schedule');
return {
...state,
schedule: action.schedule,
isCustom,
fields: {
payee: schedule._payee,
account: schedule._account,
// defalut to a non-zero value so the sign can be changed before the value
amount: schedule._amount || -1000,
amountOp: schedule._amountOp || 'isapprox',
date: schedule._date,
posts_transaction: action.schedule.posts_transaction,
name: schedule.name,
},
};
}
case 'set-field':
if (!(action.field in state.fields)) {
throw new Error('Unknown field: ' + action.field);
}
const fields = { [action.field]: action.value };
// If we are changing the amount operator either to or
// away from the `isbetween` operator, the amount value is
// different and we need to convert it
if (
action.field === 'amountOp' &&
action.value !== state.fields.amountOp
) {
if (action.value === 'isbetween') {
// We need a range if switching to `isbetween`. The
// amount field should be a number since we are
// switching away from the other ops, but check just in
// case
fields.amount =
typeof state.fields.amount === 'number'
? { num1: state.fields.amount, num2: state.fields.amount }
: { num1: 0, num2: 0 };
} else if (state.fields.amountOp === 'isbetween') {
// We need just a number if switching away from
// `isbetween`. The amount field should be a range, but
// also check just in case. We grab just the first
// number and use it
fields.amount =
typeof state.fields.amount === 'number'
? state.fields.amount
: state.fields.amount.num1;
}
}
return {
...state,
fields: { ...state.fields, ...fields },
};
case 'set-transactions':
if (fromTrans && action.transactions) {
action.transactions.sort(a => {
return transaction.id === a.id ? -1 : 1;
});
}
return { ...state, transactions: action.transactions };
case 'set-repeats':
return {
...state,
fields: {
...state.fields,
date: action.repeats
? {
frequency: 'monthly',
start: monthUtils.currentDay(),
patterns: [],
}
: monthUtils.currentDay(),
},
};
case 'set-upcoming-dates':
return {
...state,
upcomingDates: action.dates,
};
case 'form-error':
return { ...state, error: action.error };
case 'switch-transactions':
return { ...state, transactionsMode: action.mode };
default:
throw new Error('Unknown action: ' + action.type);
}
},
{
schedule: null,
upcomingDates: null,
error: null,
fields: {
payee: null,
account: null,
amount: null,
amountOp: null,
date: null,
posts_transaction: false,
name: null,
},
transactions: [],
transactionsMode: adding ? 'matched' : 'linked',
},
);
async function loadSchedule() {
const { data } = await runQuery(q('schedules').filter({ id }).select('*'));
return data[0];
}
useEffect(() => {
async function run() {
if (adding) {
const date = {
start: monthUtils.currentDay(),
frequency: 'monthly',
patterns: [],
skipWeekend: false,
weekendSolveMode: 'after',
endMode: 'never',
endOccurrences: '1',
endDate: monthUtils.currentDay(),
};
const schedule = fromTrans
? {
posts_transaction: false,
_conditions: [{ op: 'isapprox', field: 'date', value: date }],
_actions: [],
_account: transaction.account,
_amount: transaction.amount,
_amountOp: 'is',
name: transaction.payee ? payees[transaction.payee].name : '',
_payee: transaction.payee ? transaction.payee : '',
_date: {
...date,
frequency: 'monthly',
start: transaction.date,
patterns: [],
},
}
: {
posts_transaction: false,
_date: date,
_conditions: [{ op: 'isapprox', field: 'date', value: date }],
_actions: [],
};
dispatch({ type: 'set-schedule', schedule });
} else {
const schedule = await loadSchedule();
if (schedule && state.schedule == null) {
dispatch({ type: 'set-schedule', schedule });
}
}
}
run();
}, []);
useEffect(() => {
async function run() {
const date = state.fields.date;
if (date == null) {
dispatch({ type: 'set-upcoming-dates', dates: null });
} else {
if (date.frequency) {
const { data } = await sendCatch('schedule/get-upcoming-dates', {
config: date,
count: 3,
});
dispatch({ type: 'set-upcoming-dates', dates: data });
} else {
const today = monthUtils.currentDay();
if (date === today || monthUtils.isAfter(date, today)) {
dispatch({ type: 'set-upcoming-dates', dates: [date] });
} else {
dispatch({ type: 'set-upcoming-dates', dates: null });
}
}
}
}
run();
}, [state.fields.date]);
useEffect(() => {
if (
state.schedule &&
state.schedule.id &&
state.transactionsMode === 'linked'
) {
const live = liveQuery(
q('transactions')
.filter({ schedule: state.schedule.id })
.select('*')
.options({ splits: 'all' }),
data => dispatch({ type: 'set-transactions', transactions: data }),
);
return live.unsubscribe;
}
}, [state.schedule, state.transactionsMode]);
useEffect(() => {
let current = true;
let unsubscribe;
if (state.schedule && state.transactionsMode === 'matched') {
const { error, conditions: originalConditions } =
updateScheduleConditions(state.schedule, state.fields);
if (error) {
dispatch({ type: 'form-error', error });
return;
}
// *Extremely* gross hack because the rules are not mapped to
// public names automatically. We really should be doing that
// at the database layer
const conditions = originalConditions.map(cond => {
if (cond.field === 'description') {
return { ...cond, field: 'payee' };
} else if (cond.field === 'acct') {
return { ...cond, field: 'account' };
}
return cond;
});
send('make-filters-from-conditions', {
conditions,
}).then(({ filters }) => {
if (current) {
const live = liveQuery(
q('transactions')
.filter({ $and: filters })
.select('*')
.options({ splits: 'all' }),
data => dispatch({ type: 'set-transactions', transactions: data }),
);
unsubscribe = live.unsubscribe;
}
});
}
return () => {
current = false;
if (unsubscribe) {
unsubscribe();
}
};
}, [state.schedule, state.transactionsMode, state.fields]);
const selectedInst = useSelected(
'transactions',
state.transactions,
transaction ? [transaction.id] : [],
);
async function onSave() {
dispatch({ type: 'form-error', error: null });
if (state.fields.name) {
const { data: sameName } = await runQuery(
q('schedules').filter({ name: state.fields.name }).select('id'),
);
if (sameName.length > 0 && sameName[0].id !== state.schedule.id) {
dispatch({
type: 'form-error',
error: 'There is already a schedule with this name',
});
return;
}
}
const { error, conditions } = updateScheduleConditions(
state.schedule,
state.fields,
);
if (error) {
dispatch({ type: 'form-error', error });
return;
}
const res = await sendCatch(
adding ? 'schedule/create' : 'schedule/update',
{
schedule: {
id: state.schedule.id,
posts_transaction: state.fields.posts_transaction,
name: state.fields.name,
},
conditions,
},
);
if (res.error) {
dispatch({
type: 'form-error',
error:
'An error occurred while saving. Please visit https://actualbudget.org/contact/ for support.',
});
} else {
if (adding) {
await onLinkTransactions([...selectedInst.items], res.data);
}
actions.popModal();
}
}
async function onEditRule(ruleId) {
const rule = await send('rule-get', { id: ruleId || state.schedule.rule });
globalDispatch(
pushModal('edit-rule', {
rule,
onSave: async () => {
const schedule = await loadSchedule();
dispatch({ type: 'set-schedule', schedule });
},
}),
);
}
async function onLinkTransactions(ids, scheduleId) {
await send('transactions-batch-update', {
updated: ids.map(id => ({
id,
schedule: scheduleId || state.schedule.id,
})),
});
selectedInst.dispatch({ type: 'select-none' });
}
async function onUnlinkTransactions(ids) {
await send('transactions-batch-update', {
updated: ids.map(id => ({ id, schedule: null })),
});
selectedInst.dispatch({ type: 'select-none' });
}
if (state.schedule == null) {
return null;
}
function onSwitchTransactions(mode) {
dispatch({ type: 'switch-transactions', mode });
selectedInst.dispatch({ type: 'select-none' });
}
const payee = payees ? payees[state.fields.payee] : null;
// This is derived from the date
const repeats = state.fields.date ? !!state.fields.date.frequency : false;
return (
<Modal
title={payee ? `Schedule: ${payee.name}` : 'Schedule'}
size="medium"
{...modalProps}
>
<Stack direction="row" style={{ marginTop: 10 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title="Schedule Name" htmlFor="name-field" />
<GenericInput
field="string"
type="string"
value={state.fields.name}
multi={false}
onChange={e => {
dispatch({ type: 'set-field', field: 'name', value: e });
}}
/>
</FormField>
</Stack>
<Stack direction="row" style={{ marginTop: 20 }}>
<FormField style={{ flex: 1 }}>
<FormLabel title="Payee" id="payee-label" htmlFor="payee-field" />
<PayeeAutocomplete
value={state.fields.payee}
labelProps={{ id: 'payee-label' }}
inputProps={{ id: 'payee-field', placeholder: '(none)' }}
onSelect={id =>
dispatch({ type: 'set-field', field: 'payee', value: id })
}
isCreatable
/>
</FormField>
<FormField style={{ flex: 1 }}>
<FormLabel
title="Account"
id="account-label"
htmlFor="account-field"
/>
<AccountAutocomplete
includeClosedAccounts={false}
value={state.fields.account}
labelProps={{ id: 'account-label' }}
inputProps={{ id: 'account-field', placeholder: '(none)' }}
onSelect={id =>
dispatch({ type: 'set-field', field: 'account', value: id })
}
/>
</FormField>
<FormField style={{ flex: 1 }}>
<Stack direction="row" align="center" style={{ marginBottom: 3 }}>
<FormLabel
title="Amount"
htmlFor="amount-field"
style={{ margin: 0, flex: 1 }}
/>
<OpSelect
ops={['isapprox', 'is', 'isbetween']}
value={state.fields.amountOp}
formatOp={op => {
switch (op) {
case 'is':
return 'is exactly';
case 'isapprox':
return 'is approximately';
case 'isbetween':
return 'is between';
default:
throw new Error('Invalid op for select: ' + op);
}
}}
style={{
padding: '0 10px',
color: theme.pageTextLight,
fontSize: 12,
}}
onChange={(_, op) =>
dispatch({ type: 'set-field', field: 'amountOp', value: op })
}
/>
</Stack>
{state.fields.amountOp === 'isbetween' ? (
<BetweenAmountInput
defaultValue={state.fields.amount}
onChange={value =>
dispatch({
type: 'set-field',
field: 'amount',
value,
})
}
/>
) : (
<AmountInput
id="amount-field"
value={state.fields.amount}
onUpdate={value =>
dispatch({
type: 'set-field',
field: 'amount',
value,
})
}
/>
)}
</FormField>
</Stack>
<View style={{ marginTop: 20 }}>
<FormLabel title="Date" />
</View>
<Stack direction="row" align="flex-start" justify="space-between">
<View style={{ width: '13.44rem' }}>
{repeats ? (
<RecurringSchedulePicker
value={state.fields.date}
onChange={value =>
dispatch({ type: 'set-field', field: 'date', value })
}
/>
) : (
<DateSelect
value={state.fields.date}
onSelect={date =>
dispatch({ type: 'set-field', field: 'date', value: date })
}
dateFormat={dateFormat}
/>
)}
{state.upcomingDates && (
<View style={{ fontSize: 13, marginTop: 20 }}>
<Text style={{ color: theme.pageTextLight, fontWeight: 600 }}>
Upcoming dates
</Text>
<Stack
direction="column"
spacing={1}
style={{ marginTop: 10, color: theme.pageTextLight }}
>
{state.upcomingDates.map(date => (
<View key={date}>
{monthUtils.format(date, `${dateFormat} EEEE`)}
</View>
))}
</Stack>
</View>
)}
</View>
<View
style={{
marginTop: 5,
flexDirection: 'row',
alignItems: 'center',
userSelect: 'none',
}}
>
<Checkbox
id="form_repeats"
checked={repeats}
onChange={e => {
dispatch({ type: 'set-repeats', repeats: e.target.checked });
}}
/>
<label htmlFor="form_repeats" style={{ userSelect: 'none' }}>
Repeats
</label>
</View>
<Stack align="flex-end">
<View
style={{
marginTop: 5,
flexDirection: 'row',
alignItems: 'center',
userSelect: 'none',
justifyContent: 'flex-end',
}}
>
<Checkbox
id="form_posts_transaction"
checked={state.fields.posts_transaction}
onChange={e => {
dispatch({
type: 'set-field',
field: 'posts_transaction',
value: e.target.checked,
});
}}
/>
<label
htmlFor="form_posts_transaction"
style={{ userSelect: 'none' }}
>
Automatically add transaction
</label>
</View>
<Text
style={{
width: 350,
textAlign: 'right',
color: theme.pageTextLight,
marginTop: 10,
fontSize: 13,
lineHeight: '1.4em',
}}
>
If checked, the schedule will automatically create transactions for
you in the specified account
</Text>
{!adding && state.schedule.rule && (
<Stack direction="row" align="center" style={{ marginTop: 20 }}>
{state.isCustom && (
<Text
style={{
color: theme.pageTextLight,
fontSize: 13,
textAlign: 'right',
width: 350,
}}
>
This schedule has custom conditions and actions
</Text>
)}
<Button onClick={() => onEditRule()} disabled={adding}>
Edit as rule
</Button>
</Stack>
)}
</Stack>
</Stack>
<View style={{ marginTop: 30, flex: 1 }}>
<SelectedProvider instance={selectedInst}>
{adding ? (
<View style={{ flexDirection: 'row', padding: '5px 0' }}>
<Text style={{ color: theme.pageTextLight }}>
These transactions match this schedule:
</Text>
<View style={{ flex: 1 }} />
<Text style={{ color: theme.pageTextLight }}>
Select transactions to link on save
</Text>
</View>
) : (
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Button
type="bare"
style={{
color:
state.transactionsMode === 'linked'
? theme.pageTextLink
: theme.pageTextSubdued,
marginRight: 10,
fontSize: 14,
}}
onClick={() => onSwitchTransactions('linked')}
>
Linked transactions
</Button>{' '}
<Button
type="bare"
style={{
color:
state.transactionsMode === 'matched'
? theme.pageTextLink
: theme.pageTextSubdued,
fontSize: 14,
}}
onClick={() => onSwitchTransactions('matched')}
>
Find matching transactions
</Button>
<View style={{ flex: 1 }} />
<SelectedItemsButton
name="transactions"
items={
state.transactionsMode === 'linked'
? [{ name: 'unlink', text: 'Unlink from schedule' }]
: [{ name: 'link', text: 'Link to schedule' }]
}
onSelect={(name, ids) => {
switch (name) {
case 'link':
onLinkTransactions(ids);
break;
case 'unlink':
onUnlinkTransactions(ids);
break;
default:
}
}}
/>
</View>
)}
<SimpleTransactionsTable
renderEmpty={
<NoTransactionsMessage
error={state.error}
transactionsMode={state.transactionsMode}
/>
}
transactions={state.transactions}
fields={['date', 'payee', 'amount']}
style={{
border: '1px solid ' + theme.tableBorder,
borderRadius: 4,
overflow: 'hidden',
marginTop: 5,
maxHeight: 200,
}}
/>
</SelectedProvider>
</View>
<Stack
direction="row"
justify="flex-end"
align="center"
style={{ marginTop: 20 }}
>
{state.error && (
<Text style={{ color: theme.errorText }}>{state.error}</Text>
)}
<Button style={{ marginRight: 10 }} onClick={actions.popModal}>
Cancel
</Button>
<Button type="primary" onClick={onSave}>
{adding ? 'Add' : 'Save'}
</Button>
</Stack>
</Modal>
);
}
function NoTransactionsMessage(props) {
return (
<View
style={{
padding: 20,
color: theme.pageTextLight,
textAlign: 'center',
}}
>
{props.error ? (
<Text style={{ color: theme.errorText }}>
Could not search: {props.error}
</Text>
) : props.transactionsMode === 'matched' ? (
'No matching transactions'
) : (
'No linked transactions'
)}
</View>
);
}